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
97 changes: 64 additions & 33 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import re
import traceback
from collections.abc import Mapping
from datetime import datetime

_ZH_REASON_REPLACEMENTS = (
Expand Down Expand Up @@ -167,12 +168,47 @@ def _build_benchmark_lines(execution, *, translator):
]


def _format_holdings_lines(portfolio_rows, market_values, *, translator) -> list[str]:
lines = [translator("holdings_title")]
for row in portfolio_rows:
for symbol in row:
lines.append(f" - {symbol}: ${market_values[symbol]:,.2f}")
return lines
def _normalize_cash_by_currency(raw_cash) -> dict[str, float]:
if not isinstance(raw_cash, Mapping):
return {}
cash_by_currency: dict[str, float] = {}
for currency, amount in raw_cash.items():
normalized_currency = str(currency or "").strip().upper()
if not normalized_currency:
continue
cash_by_currency[normalized_currency] = float(amount)
return cash_by_currency


def _format_cash_by_currency(cash_by_currency: Mapping[str, float]) -> str:
parts = []
for currency in sorted(cash_by_currency, key=lambda value: (value != "USD", value)):
amount = float(cash_by_currency[currency])
if amount == 0.0:
continue
parts.append(f"{currency} {amount:,.2f}")
return ", ".join(parts)


def _has_positive_non_usd_cash(cash_by_currency: Mapping[str, float]) -> bool:
return any(
currency != "USD" and float(amount) > 0.0
for currency, amount in cash_by_currency.items()
)


def _format_dashboard_text(text) -> str:
return "\n".join(
line.rstrip()
for line in str(text or "").splitlines()
if line.strip()
)


def _append_dashboard_lines(lines, *, execution) -> None:
dashboard_text = _format_dashboard_text(execution.get("dashboard_text"))
if dashboard_text:
lines.extend(dashboard_text.splitlines())


def _append_status_lines(lines, *, execution, translator, signal_key):
Expand Down Expand Up @@ -317,11 +353,10 @@ def fetch_replanned_state():
quantities = dict(portfolio["quantities"])
sellable_quantities = dict(portfolio["sellable_quantities"])
target_values = dict(allocation["targets"])
total_strategy_equity = float(portfolio["total_equity"])
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"])
portfolio_rows = tuple(portfolio["portfolio_rows"])
def record_dry_run(symbol, side, quantity, price, *, order_type):
price_text = f"${price:.2f}" if price is not None else translator("order_type_market")
side_key = "side_buy" if str(side).lower() == "buy" else "side_sell"
Expand Down Expand Up @@ -439,11 +474,25 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
quantities = dict(portfolio["quantities"])
sellable_quantities = dict(portfolio["sellable_quantities"])
target_values = dict(allocation["targets"])
total_strategy_equity = float(portfolio["total_equity"])
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"])
portfolio_rows = tuple(portfolio["portfolio_rows"])

if (
available_cash <= 0.0
and investable_cash <= 0.0
and _has_positive_non_usd_cash(cash_by_currency)
):
record_note_log(
note_logs,
translator=translator,
with_prefix=with_prefix,
kind="buy_deferred_non_usd_cash",
available=f"{available_cash:.2f}",
investable=f"{investable_cash:.2f}",
currencies=_format_cash_by_currency(cash_by_currency),
)
buy_candidates = [
symbol
for symbol in strategy_assets
Expand Down Expand Up @@ -552,11 +601,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
)

if action_done:
cash_summary = translator(
"cash_summary",
available=f"{available_cash:.2f}",
investable=f"{investable_cash:.2f}",
)
formatted_logs = "\n".join(f" - {log}" for log in [*logs, *skip_logs, *note_logs])
tg_lines = [translator("rebalance_title")]
_append_strategy_line(
Expand All @@ -566,7 +610,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
)
if dry_run_only:
tg_lines.append(translator("dry_run_banner"))
tg_lines.append(cash_summary)
_append_dashboard_lines(tg_lines, execution=execution)
_append_status_lines(
tg_lines,
execution=execution,
Expand All @@ -583,6 +627,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
)
if dry_run_only:
compact_lines.append(translator("dry_run_banner"))
_append_dashboard_lines(compact_lines, execution=execution)
_append_compact_status_lines(
compact_lines,
execution=execution,
Expand All @@ -594,13 +639,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
print(with_prefix(detailed_tg_message), flush=True)
send_tg_message(compact_tg_message)
else:
equity_text = f"{total_strategy_equity:,.2f}"
cash_summary = translator(
"cash_summary",
available=f"{available_cash:.2f}",
investable=f"{investable_cash:.2f}",
)
holdings_lines = _format_holdings_lines(portfolio_rows, market_values, translator=translator)
no_trade_lines = [
translator("heartbeat_title"),
]
Expand All @@ -609,17 +647,10 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
strategy_display_name=strategy_display_name,
translator=translator,
)
no_trade_lines.append(translator("equity", value=equity_text))
if dry_run_only:
no_trade_lines.append(translator("dry_run_banner"))
no_trade_lines.extend(
[
cash_summary,
separator,
*holdings_lines,
separator,
]
)
_append_dashboard_lines(no_trade_lines, execution=execution)
no_trade_lines.append(separator)
_append_status_lines(
no_trade_lines,
execution=execution,
Expand Down Expand Up @@ -653,9 +684,9 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
strategy_display_name=strategy_display_name,
translator=translator,
)
compact_no_trade_lines.append(translator("equity", value=equity_text))
if dry_run_only:
compact_no_trade_lines.append(translator("dry_run_banner"))
_append_dashboard_lines(compact_no_trade_lines, execution=execution)
_append_compact_status_lines(
compact_no_trade_lines,
execution=execution,
Expand Down
51 changes: 50 additions & 1 deletion decision_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,39 @@ def _build_portfolio_inputs(
raise ValueError("LongBridge plan mapping requires account_state or snapshot")


def _cash_by_currency_from_account_state(
account_state: Mapping[str, Any] | None,
) -> dict[str, float]:
if account_state is None:
return {}
raw_cash = account_state.get("cash_by_currency")
if not isinstance(raw_cash, Mapping):
return {}
cash_by_currency: dict[str, float] = {}
for currency, amount in raw_cash.items():
normalized_currency = str(currency or "").strip().upper()
if not normalized_currency:
continue
cash_by_currency[normalized_currency] = float(amount)
return cash_by_currency


def _cash_by_currency_from_snapshot(snapshot: Any | None) -> dict[str, float]:
metadata = getattr(snapshot, "metadata", {}) or {}
if not isinstance(metadata, Mapping):
return {}
raw_cash = metadata.get("cash_by_currency")
if not isinstance(raw_cash, Mapping):
return {}
cash_by_currency: dict[str, float] = {}
for currency, amount in raw_cash.items():
normalized_currency = str(currency or "").strip().upper()
if not normalized_currency:
continue
cash_by_currency[normalized_currency] = float(amount)
return cash_by_currency


def _symbol_role(symbol: str) -> str | None:
normalized = str(symbol or "").strip().upper()
if normalized in _SAFE_HAVEN_SYMBOLS:
Expand All @@ -59,6 +92,8 @@ def _build_weight_translation_annotations(
liquid_cash: float,
) -> ValueTargetExecutionAnnotations:
diagnostics = dict(decision.diagnostics)
raw_annotations = diagnostics.get("execution_annotations")
execution_annotations = dict(raw_annotations) if isinstance(raw_annotations, Mapping) else {}
threshold_value = _default_threshold_value(total_equity)
signal_display = str(
diagnostics.get("signal_description")
Expand All @@ -72,12 +107,18 @@ def _build_weight_translation_annotations(
or diagnostics.get("canary_status")
or ""
).strip() or None
dashboard_text = str(
execution_annotations.get("dashboard_text")
or diagnostics.get("dashboard")
or ""
).strip() or None
benchmark_symbol = str(diagnostics.get("benchmark_symbol") or "").strip().upper() or None
return ValueTargetExecutionAnnotations(
trade_threshold_value=threshold_value,
reserved_cash=0.0,
signal_display=signal_display,
status_display=status_display,
dashboard_text=dashboard_text,
benchmark_symbol=benchmark_symbol,
benchmark_price=(
float(diagnostics["benchmark_price"])
Expand Down Expand Up @@ -185,6 +226,7 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[
"reserved_cash",
"signal_display",
"status_display",
"dashboard_text",
"benchmark_symbol",
"benchmark_price",
"long_trend_value",
Expand All @@ -196,6 +238,7 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[
"reserved_cash": 0.0,
"signal_display": "",
"status_display": "",
"dashboard_text": "",
"benchmark_symbol": "QQQ",
"benchmark_price": 0.0,
"long_trend_value": 0.0,
Expand Down Expand Up @@ -283,7 +326,7 @@ def map_strategy_decision_to_plan(
strategy_symbols_order, portfolio_rows_layout, execution_fields, execution_defaults = _resolve_layout(
canonical_profile
)
return build_value_target_runtime_plan(
plan = build_value_target_runtime_plan(
normalized_decision,
strategy_profile=canonical_profile,
portfolio_inputs=portfolio_inputs,
Expand All @@ -294,3 +337,9 @@ def map_strategy_decision_to_plan(
execution_fields=execution_fields,
execution_defaults=execution_defaults,
)
cash_by_currency = _cash_by_currency_from_account_state(account_state)
if not cash_by_currency:
cash_by_currency = _cash_by_currency_from_snapshot(snapshot)
if cash_by_currency:
plan["portfolio"]["cash_by_currency"] = cash_by_currency
return plan
4 changes: 2 additions & 2 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
"equity": "💰 净值: ${value}",
"cash_summary": "💵 资金\n - 账户现金: ${available}\n - 可投资现金: ${investable}",
"cash_label": "现金",
"holdings_title": "💼 持仓",
"order_logs_title": "🧾 执行明细",
"benchmark_title": "📈 {symbol} 基准",
"benchmark_price": "{symbol}: {value}",
Expand All @@ -50,6 +49,7 @@
"sell_skipped": "⚪️ [卖出跳过] {detail}",
"buy_deferred": "ℹ️ [买入说明] {detail}",
"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_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用",
"limit_buy": "📈 [限价买入] {symbol}: {qty}股 @ ${price}",
Expand Down Expand Up @@ -101,7 +101,6 @@
"equity": "💰 Equity: ${value}",
"cash_summary": "💵 Cash\n - Account cash: ${available}\n - Investable cash: ${investable}",
"cash_label": "Cash",
"holdings_title": "💼 Holdings",
"order_logs_title": "🧾 Execution details",
"benchmark_title": "📈 {symbol} Benchmark",
"benchmark_price": "{symbol}: {value}",
Expand All @@ -123,6 +122,7 @@
"sell_skipped": "⚪️ [Sell skipped] {detail}",
"buy_deferred": "ℹ️ [Buy note] {detail}",
"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_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}",
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@v0.7.18
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.28
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.19
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.30
pandas
requests
pytz
Expand Down
Loading