Skip to content

Commit 3190f0e

Browse files
committed
Improve Firstrade small-account execution notices
1 parent 1ba5145 commit 3190f0e

5 files changed

Lines changed: 349 additions & 13 deletions

File tree

application/execution_service.py

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,39 @@ class ExecutionCycleResult:
134134
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
135135
SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0
136136
SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset({"TQQQ", "SOXL"})
137+
SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = {
138+
"SOXX": 0.90,
139+
}
140+
SMALL_ACCOUNT_WHOLE_SHARE_BOOTSTRAP_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = {
141+
"TQQQ": 0.90,
142+
"SOXL": 0.90,
143+
"SOXX": 0.90,
144+
}
145+
146+
147+
def _limit_buy_premium_for_symbol(symbol, default_premium, premium_by_symbol=None) -> float:
148+
normalized_symbol = str(symbol or "").strip().upper()
149+
try:
150+
fallback = float(default_premium)
151+
except (TypeError, ValueError):
152+
fallback = 1.005
153+
if not isinstance(premium_by_symbol, dict):
154+
return fallback
155+
raw_value = premium_by_symbol.get(normalized_symbol)
156+
if raw_value is None:
157+
return fallback
158+
try:
159+
premium = float(raw_value)
160+
except (TypeError, ValueError):
161+
return fallback
162+
return premium if premium > 0.0 else fallback
163+
164+
165+
def _limit_buy_price(symbol, price, default_premium, premium_by_symbol=None) -> float:
166+
return round(
167+
float(price) * _limit_buy_premium_for_symbol(symbol, default_premium, premium_by_symbol),
168+
2,
169+
)
137170

138171

139172
def _floor_quantity(quantity: float) -> int:
@@ -210,6 +243,35 @@ def substitute_small_safe_haven_targets_with_cash(
210243
return adjusted_plan
211244

212245

246+
def _should_retain_existing_whole_share(symbol, *, target_value, price) -> bool:
247+
normalized_symbol = str(symbol or "").strip().upper()
248+
if normalized_symbol in SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS:
249+
return True
250+
251+
min_target_share_ratio = (
252+
SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL.get(normalized_symbol)
253+
)
254+
if min_target_share_ratio is None:
255+
return False
256+
quote_price = max(0.0, float(price or 0.0))
257+
if quote_price <= 0.0:
258+
return False
259+
return max(0.0, float(target_value or 0.0)) >= quote_price * float(min_target_share_ratio)
260+
261+
262+
def _should_bootstrap_whole_share_buy(symbol, *, target_value, limit_price) -> bool:
263+
normalized_symbol = str(symbol or "").strip().upper()
264+
min_target_share_ratio = (
265+
SMALL_ACCOUNT_WHOLE_SHARE_BOOTSTRAP_MIN_TARGET_SHARE_RATIO_BY_SYMBOL.get(normalized_symbol)
266+
)
267+
if min_target_share_ratio is None:
268+
return False
269+
effective_limit_price = max(0.0, float(limit_price or 0.0))
270+
if effective_limit_price <= 0.0:
271+
return False
272+
return max(0.0, float(target_value or 0.0)) >= effective_limit_price * float(min_target_share_ratio)
273+
274+
213275
def _quote_price(market_data_port: MarketDataPort, symbol: str) -> float | None:
214276
try:
215277
price = float(market_data_port.get_quote(symbol).last_price)
@@ -222,6 +284,8 @@ def _apply_small_account_whole_share_compatibility(
222284
plan: dict[str, Any],
223285
*,
224286
market_data_port: MarketDataPort,
287+
limit_buy_premium: float = 1.005,
288+
limit_buy_premium_by_symbol: dict[str, float] | None = None,
225289
) -> dict[str, Any]:
226290
adjusted_plan = dict(plan or {})
227291
allocation = dict(adjusted_plan.get("allocation") or {})
@@ -248,6 +312,7 @@ def _apply_small_account_whole_share_compatibility(
248312
if price is not None:
249313
prices[str(symbol).strip().upper()] = price
250314
retained_symbols = []
315+
bootstrap_symbols = []
251316
quantities = {
252317
str(symbol or "").strip().upper(): float(quantity or 0.0)
253318
for symbol, quantity in dict(portfolio.get("quantities") or {}).items()
@@ -257,13 +322,33 @@ def _apply_small_account_whole_share_compatibility(
257322
for symbol, value in targets.items()
258323
}
259324
for symbol in candidate_symbols:
260-
if symbol not in SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS:
261-
continue
262325
target_value = max(0.0, float(compatibility_targets.get(symbol, 0.0) or 0.0))
263326
price = max(0.0, float(prices.get(symbol, 0.0) or 0.0))
327+
limit_price = (
328+
_limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol)
329+
if price > 0.0
330+
else 0.0
331+
)
332+
if not _should_retain_existing_whole_share(symbol, target_value=target_value, price=price):
333+
if (
334+
quantities.get(symbol, 0.0) <= 0.0
335+
and 0.0 < target_value < limit_price
336+
and _should_bootstrap_whole_share_buy(symbol, target_value=target_value, limit_price=limit_price)
337+
):
338+
compatibility_targets[symbol] = limit_price
339+
bootstrap_symbols.append(symbol)
340+
continue
264341
if price > 0.0 and 0.0 < target_value < price and quantities.get(symbol, 0.0) >= 1.0:
265342
compatibility_targets[symbol] = price
266343
retained_symbols.append(symbol)
344+
continue
345+
if (
346+
quantities.get(symbol, 0.0) <= 0.0
347+
and 0.0 < target_value < limit_price
348+
and _should_bootstrap_whole_share_buy(symbol, target_value=target_value, limit_price=limit_price)
349+
):
350+
compatibility_targets[symbol] = limit_price
351+
bootstrap_symbols.append(symbol)
267352
safe_haven_symbols = _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation)
268353
compatibility = apply_small_account_cash_compatibility(
269354
compatibility_targets,
@@ -285,6 +370,10 @@ def _apply_small_account_whole_share_compatibility(
285370
allocation["small_account_existing_whole_share_retained_symbols"] = tuple(
286371
dict.fromkeys(retained_symbols)
287372
)
373+
if bootstrap_symbols:
374+
allocation["small_account_whole_share_bootstrap_symbols"] = tuple(
375+
dict.fromkeys(bootstrap_symbols)
376+
)
288377
if compatibility.cash_substitution_notes:
289378
allocation["small_account_whole_share_cash_notes"] = tuple(compatibility.cash_substitution_notes)
290379
adjusted_plan["allocation"] = allocation
@@ -334,6 +423,7 @@ def execute_value_target_plan(
334423
dry_run_only: bool,
335424
limit_sell_discount: float = 0.995,
336425
limit_buy_premium: float = 1.005,
426+
limit_buy_premium_by_symbol: dict[str, float] | None = None,
337427
max_order_notional_usd: float | None = None,
338428
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
339429
) -> ExecutionCycleResult:
@@ -345,6 +435,8 @@ def execute_value_target_plan(
345435
plan = _apply_small_account_whole_share_compatibility(
346436
plan,
347437
market_data_port=market_data_port,
438+
limit_buy_premium=limit_buy_premium,
439+
limit_buy_premium_by_symbol=limit_buy_premium_by_symbol,
348440
)
349441
allocation = dict(plan.get("allocation") or {})
350442
portfolio = dict(plan.get("portfolio") or {})
@@ -437,16 +529,17 @@ def execute_value_target_plan(
437529
buy_budget = min(float(delta_value), investable_cash)
438530
if order_notional_cap is not None:
439531
buy_budget = min(buy_budget, order_notional_cap)
440-
quantity = _floor_quantity(buy_budget / price)
532+
limit_price = _limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol)
533+
quantity = _floor_quantity(buy_budget / limit_price) if limit_price > 0 else 0
441534
if quantity <= 0:
442-
if order_notional_cap is None and investable_cash < price:
535+
if order_notional_cap is None and investable_cash < limit_price:
443536
skipped.append(
444537
{
445538
"symbol": symbol,
446539
"reason": "insufficient_cash_for_whole_share",
447-
"price": round(price, 2),
540+
"price": round(limit_price, 2),
448541
"investable_cash": round(investable_cash, 2),
449-
"required_cash_for_one_share": round(price, 2),
542+
"required_cash_for_one_share": round(limit_price, 2),
450543
}
451544
)
452545
else:
@@ -468,11 +561,11 @@ def execute_value_target_plan(
468561
symbol=symbol,
469562
side="buy",
470563
quantity=quantity,
471-
limit_price=price * float(limit_buy_premium),
564+
limit_price=limit_price,
472565
max_notional_usd=max_order_notional_usd,
473566
)
474567
)
475-
investable_cash = max(0.0, investable_cash - (quantity * price))
568+
investable_cash = max(0.0, investable_cash - (quantity * limit_price))
476569

477570
return ExecutionCycleResult(
478571
submitted_orders=tuple(submitted),

application/rebalance_service.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,40 @@
6666

6767
LIMIT_SELL_DISCOUNT = 0.995
6868
LIMIT_BUY_PREMIUM = 1.005
69+
DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL = {"SOXL": 1.015, "TQQQ": 1.010}
70+
71+
72+
def _load_limit_buy_premium_by_symbol(*env_names: str) -> dict[str, float]:
73+
raw_value = ""
74+
for env_name in env_names:
75+
value = os.getenv(env_name)
76+
if value and value.strip():
77+
raw_value = value.strip()
78+
break
79+
if not raw_value:
80+
return dict(DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL)
81+
try:
82+
payload = json.loads(raw_value)
83+
except json.JSONDecodeError as exc:
84+
raise ValueError(f"Invalid limit buy premium map JSON: {raw_value!r}") from exc
85+
if not isinstance(payload, dict):
86+
raise ValueError("Limit buy premium map must be a JSON object keyed by symbol.")
87+
parsed: dict[str, float] = {}
88+
for symbol, premium in payload.items():
89+
symbol_text = str(symbol or "").strip().upper()
90+
if not symbol_text:
91+
continue
92+
premium_value = float(premium)
93+
if premium_value <= 0.0:
94+
raise ValueError(f"Limit buy premium for {symbol_text} must be positive.")
95+
parsed[symbol_text] = premium_value
96+
return parsed
97+
98+
99+
LIMIT_BUY_PREMIUM_BY_SYMBOL = _load_limit_buy_premium_by_symbol(
100+
"FIRSTRADE_LIMIT_BUY_PREMIUM_BY_SYMBOL_JSON",
101+
"LIMIT_BUY_PREMIUM_BY_SYMBOL_JSON",
102+
)
69103

70104

71105
def _utcnow() -> datetime:
@@ -512,6 +546,7 @@ def log_message(message: str) -> None:
512546
dry_run_only=settings.dry_run_only,
513547
limit_sell_discount=LIMIT_SELL_DISCOUNT,
514548
limit_buy_premium=LIMIT_BUY_PREMIUM,
549+
limit_buy_premium_by_symbol=LIMIT_BUY_PREMIUM_BY_SYMBOL,
515550
max_order_notional_usd=settings.max_order_notional_usd,
516551
safe_haven_cash_substitute_threshold_usd=settings.safe_haven_cash_substitute_threshold_usd,
517552
)

notifications/telegram.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,46 @@ def format_small_account_cash_substitution_notes(
111111
return tuple(messages)
112112

113113

114+
def _format_symbol_with_suffix(symbol, *, suffix=".US") -> str:
115+
normalized = str(symbol or "").strip().upper()
116+
if not normalized:
117+
return normalized
118+
if "." in normalized:
119+
return normalized
120+
normalized_suffix = str(suffix or "").strip().upper()
121+
return f"{normalized}{normalized_suffix}" if normalized_suffix else normalized
122+
123+
124+
def format_small_account_whole_share_bootstrap_notes(
125+
symbols,
126+
*,
127+
translator,
128+
symbol_suffix=".US",
129+
) -> tuple[str, ...]:
130+
normalized_symbols = tuple(
131+
dict.fromkeys(
132+
_format_symbol_with_suffix(symbol, suffix=symbol_suffix)
133+
for symbol in tuple(symbols or ())
134+
if str(symbol or "").strip()
135+
)
136+
)
137+
if not normalized_symbols:
138+
return ()
139+
try:
140+
message = translator(
141+
"buy_lifted_small_account_whole_share",
142+
symbols=", ".join(normalized_symbols),
143+
)
144+
except Exception:
145+
message = ""
146+
if not message or message == "buy_lifted_small_account_whole_share":
147+
message = (
148+
f"ℹ️ [买入说明] {', '.join(normalized_symbols)} 目标金额接近 1 股;"
149+
"小账户整数股兼容,本轮允许按 1 股下单"
150+
)
151+
return (message,)
152+
153+
114154
SEPARATOR = "━━━━━━━━━━━━━━━━━━"
115155

116156
_DETAIL_FIELD_SPLIT_RE = re.compile(r",\s*(?=[A-Za-z_][\w-]*\s*=)")
@@ -138,7 +178,15 @@ def format_small_account_cash_substitution_notes(
138178
"quantity_share": "{quantity}股",
139179
"quantity_shares": "{quantity}股",
140180
"signal_label": "信号",
141-
"strategy_plugin_line": "🧩 插件:{plugin} | 状态:{route} | 提醒:{action}",
181+
"strategy_plugin_line": "🧩 插件:{plugin} | 启用:{enabled} | 状态:{route} | 提醒:{action}",
182+
"strategy_plugin_enabled_true": "是",
183+
"strategy_plugin_enabled_false": "否",
184+
"strategy_plugin_consumption_auto": "🧩 插件消费:已按策略规则参与本轮仓位计算",
185+
"strategy_plugin_consumption_auto_defend": "🧩 插件消费:已按策略规则参与本轮仓位计算;风险仓位按防守规则处理",
186+
"strategy_plugin_consumption_auto_delever": "🧩 插件消费:已按策略规则参与本轮仓位计算;杠杆仓位按降档规则缩放",
187+
"strategy_plugin_consumption_loaded_not_applied": "🧩 插件消费:已加载但未改写仓位;当前策略未启用该状态的自动消费",
188+
"strategy_plugin_consumption_review_only": "🧩 插件消费:仅通知复核,未参与自动仓位计算",
189+
"strategy_plugin_consumption_unavailable": "🧩 插件消费:未消费插件信号",
142190
"strategy_plugin_alert_subject": "🚨 策略插件告警:{plugin} | {route}",
143191
"strategy_plugin_alert_title": "🚨 【策略插件告警】",
144192
"strategy_plugin_alert_context": "运行环境:{context}",
@@ -196,7 +244,7 @@ def format_small_account_cash_substitution_notes(
196244
"target_diff_summary": "调仓变化: {details}",
197245
"order_logs_title": "🧾 执行明细",
198246
"dry_run_order": "🧪 模拟{order_type}{side} {symbol}: {quantity}{price}",
199-
"submitted_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}",
247+
"submitted_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}(尚未确认成交;限价单可能未成交或取消)",
200248
"order_type_limit": "限价",
201249
"order_type_market": "市价",
202250
"side_buy": "买入",
@@ -212,6 +260,7 @@ def format_small_account_cash_substitution_notes(
212260
"no_executable_orders": "无可执行订单",
213261
"buy_deferred": "ℹ️ [买入说明] {detail}",
214262
"buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}",
263+
"buy_lifted_small_account_whole_share": "ℹ️ [买入说明] {symbols} 目标金额接近 1 股;小账户整数股兼容,本轮允许按 1 股下单",
215264
"signal_state_hold": "趋势持有",
216265
"signal_state_entry": "入场信号",
217266
"signal_state_reduce": "减仓信号",
@@ -288,7 +337,15 @@ def format_small_account_cash_substitution_notes(
288337
"quantity_share": "{quantity} share",
289338
"quantity_shares": "{quantity} shares",
290339
"signal_label": "Signal",
291-
"strategy_plugin_line": "🧩 Plugin: {plugin} | status: {route} | notice: {action}",
340+
"strategy_plugin_line": "🧩 Plugin: {plugin} | enabled: {enabled} | status: {route} | notice: {action}",
341+
"strategy_plugin_enabled_true": "yes",
342+
"strategy_plugin_enabled_false": "no",
343+
"strategy_plugin_consumption_auto": "🧩 Plugin consumption: included in this cycle's position calculation under strategy rules",
344+
"strategy_plugin_consumption_auto_defend": "🧩 Plugin consumption: included in this cycle's position calculation; risk exposure follows defensive rules",
345+
"strategy_plugin_consumption_auto_delever": "🧩 Plugin consumption: included in this cycle's position calculation; leveraged exposure follows de-risking rules",
346+
"strategy_plugin_consumption_loaded_not_applied": "🧩 Plugin consumption: loaded but did not rewrite positions; this strategy does not enable automatic consumption for this state",
347+
"strategy_plugin_consumption_review_only": "🧩 Plugin consumption: review-only notice, not used for automatic position calculation",
348+
"strategy_plugin_consumption_unavailable": "🧩 Plugin consumption: no plugin signal consumed",
292349
"strategy_plugin_alert_subject": "🚨 Strategy plugin alert: {plugin} | {route}",
293350
"strategy_plugin_alert_title": "🚨 【Strategy Plugin Alert】",
294351
"strategy_plugin_alert_context": "Context: {context}",
@@ -346,7 +403,7 @@ def format_small_account_cash_substitution_notes(
346403
"target_diff_summary": "Target changes: {details}",
347404
"order_logs_title": "🧾 Execution details",
348405
"dry_run_order": "🧪 Dry-run {order_type} {side} {symbol}: {quantity}{price}",
349-
"submitted_order": "{icon} Submitted {order_type} {side} {symbol}: {quantity}{price}{order_id}",
406+
"submitted_order": "{icon} Submitted {order_type} {side} {symbol}: {quantity}{price}{order_id} (fill not confirmed; a limit order may remain unfilled or be canceled)",
350407
"order_type_limit": "limit",
351408
"order_type_market": "market",
352409
"side_buy": "buy",
@@ -362,6 +419,7 @@ def format_small_account_cash_substitution_notes(
362419
"no_executable_orders": "no executable orders",
363420
"buy_deferred": "ℹ️ [Buy note] {detail}",
364421
"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}",
422+
"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",
365423
"signal_state_hold": "Trend Hold",
366424
"signal_state_entry": "Entry Signal",
367425
"signal_state_reduce": "Reduce Signal",
@@ -1085,6 +1143,12 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
10851143
)
10861144
execution_notes = tuple(result.get("execution_notes") or allocation.get("small_account_whole_share_cash_notes") or ())
10871145
lines.extend(format_small_account_cash_substitution_notes(execution_notes, translator=translator))
1146+
lines.extend(
1147+
format_small_account_whole_share_bootstrap_notes(
1148+
allocation.get("small_account_whole_share_bootstrap_symbols") or (),
1149+
translator=translator,
1150+
)
1151+
)
10881152
if submitted:
10891153
lines.append(translator("order_logs_title"))
10901154
lines.extend(_format_order_lines(submitted, dry_run_only=dry_run_only, translator=translator))

0 commit comments

Comments
 (0)