Skip to content

Commit b82f2ac

Browse files
authored
Merge pull request #279 from QuantStrategyLab/fix/retryable-funding-window
fix: safely retry pre-submission execution blocks
2 parents 38d607f + 771f18f commit b82f2ac

14 files changed

Lines changed: 313 additions & 61 deletions

.github/workflows/sync-cloud-run-env.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,10 @@ jobs:
754754
--uri="${scheduler_uri}" \
755755
--schedule="${desired_schedule}" \
756756
--time-zone="${market_timezone}" \
757+
--max-retry-attempts=3 \
758+
--min-backoff=300s \
759+
--max-backoff=1800s \
760+
--max-doublings=2 \
757761
--quiet
758762
else
759763
echo "Creating Cloud Scheduler job ${job_name} schedule ${desired_schedule}, timezone ${market_timezone}, and URI ${scheduler_uri}."
@@ -767,6 +771,10 @@ jobs:
767771
--schedule="${desired_schedule}" \
768772
--time-zone="${market_timezone}" \
769773
--attempt-deadline=600s \
774+
--max-retry-attempts=3 \
775+
--min-backoff=300s \
776+
--max-backoff=1800s \
777+
--max-doublings=2 \
770778
--quiet
771779
fi
772780

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ It is an execution layer, not a strategy research repository. Strategy logic com
2626
- Must keep credentials in GitHub Secrets, cloud secret stores, or the broker-specific secret system, never in Git.
2727
- Should start with dry-run or paper mode before any live order path is enabled.
2828

29+
## Live retry boundary
30+
31+
The runtime retries only a cycle that made **no** broker-order request: for
32+
example, a temporary quote failure or insufficient settled cash. It writes a
33+
durable create-only claim immediately before the first live broker request, so
34+
an accepted, rejected, pending, timed-out, or otherwise unknown broker request
35+
is never sent again automatically. Funding blocks notify once and can retry on
36+
the bounded scheduler backoff or the next run inside the strategy window.
37+
2938
## Direct vs snapshot-backed profiles
3039

3140
Direct runtime profiles can usually run from market history or portfolio state. Snapshot-backed profiles need a current artifact bundle from the matching snapshot pipeline before this platform should execute them. The platform should not invent strategy eligibility; it should consume the status and artifacts published by the strategy and snapshot repositories.

README.zh-CN.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ FirstradePlatform 是 QuantStrategyLab 的实验性 Firstrade 执行平台。实
2626
- 凭据必须放在 GitHub Secrets、云密钥系统或券商专用密钥系统中,不能提交到 Git。
2727
- 任何 live 下单路径启用前,都应先从 dry-run 或 paper mode 开始。
2828

29+
## 实盘重试边界
30+
31+
运行时只会重试**从未向券商发出订单请求**的周期,例如暂时拿不到报价或可用现金不足。第一次
32+
真实券商请求前会写入持久化、仅创建一次的提交锁;因此已被券商受理、拒绝、待处理、超时或结果
33+
未知的请求都不会自动重发。资金不足只提醒一次,并会在受限的调度退避或策略窗口内的下一次运行时
34+
再次检查。
35+
2936
## 普通 profile 与 snapshot-backed profile
3037

3138
普通 runtime profile 通常可以直接基于 market history 或 portfolio state 执行。Snapshot-backed profile 需要先从对应 snapshot pipeline 获取当前 artifact bundle,平台才应该执行。平台不应该自行判断策略资格,而应消费策略仓和 snapshot 仓发布的状态与产物。

application/execution_service.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from dataclasses import dataclass
66
from math import floor
7+
from collections.abc import Callable
78
from typing import Any
89

910
from quant_platform_kit.common.order_status import compute_confirmed_sell_release_value
@@ -137,6 +138,7 @@ class ExecutionCycleResult:
137138
broker_submission_done: bool = False
138139
pending_reconciliation: bool = False
139140
execution_notes: tuple[dict[str, Any], ...] = ()
141+
idempotency_blocked: bool = False
140142

141143

142144
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
@@ -644,6 +646,7 @@ def execute_value_target_plan(
644646
cash_only_execution: bool = True,
645647
notional_buy_execution: bool = False,
646648
fetch_order_status=None,
649+
before_live_submission: Callable[[], bool] | None = None,
647650
) -> ExecutionCycleResult:
648651
del dry_run_only # ExecutionPort owns preview vs live submission.
649652
plan = substitute_small_safe_haven_targets_with_cash(
@@ -702,6 +705,24 @@ def execute_value_target_plan(
702705
submitted_sell_orders: list[dict[str, Any]] = []
703706
sell_submitted = False
704707

708+
def _submission_claim_unavailable(symbol: str) -> ExecutionCycleResult:
709+
skipped.append(
710+
{
711+
"symbol": symbol,
712+
"reason": "duplicate_live_strategy_run",
713+
}
714+
)
715+
return ExecutionCycleResult(
716+
submitted_orders=tuple(submitted),
717+
skipped_orders=tuple(skipped),
718+
action_done=False,
719+
execution_notes=tuple(execution_notes),
720+
idempotency_blocked=True,
721+
)
722+
723+
def _may_submit_live_order() -> bool:
724+
return before_live_submission is None or bool(before_live_submission())
725+
705726
tradable_deltas: list[tuple[str, float, float]] = []
706727
for symbol in sorted(set(targets) | set(market_values)):
707728
target_value = float(targets.get(symbol, 0.0))
@@ -749,6 +770,8 @@ def execute_value_target_plan(
749770
)
750771
continue
751772
sell_limit_price = price * float(limit_sell_discount)
773+
if not _may_submit_live_order():
774+
return _submission_claim_unavailable(symbol)
752775
order_result = _submit_order(
753776
execution_port,
754777
symbol=symbol,
@@ -855,6 +878,8 @@ def execute_value_target_plan(
855878
}
856879
)
857880
continue
881+
if not _may_submit_live_order():
882+
return _submission_claim_unavailable(symbol)
858883
order_result = _submit_notional_buy_order(
859884
execution_port,
860885
symbol=symbol,
@@ -903,6 +928,8 @@ def execute_value_target_plan(
903928
}
904929
)
905930
continue
931+
if not _may_submit_live_order():
932+
return _submission_claim_unavailable(symbol)
906933
order_result = _submit_order(
907934
execution_port,
908935
symbol=symbol,

application/rebalance_service.py

Lines changed: 109 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@
3131
from application.strategy_run_persistence import (
3232
build_strategy_run_state,
3333
claim_live_strategy_run,
34+
has_effective_live_submission_claim,
3435
is_duplicate_live_run,
3536
persist_strategy_run_state,
37+
read_live_strategy_run_claim,
3638
read_latest_strategy_run_state,
3739
resolve_strategy_run_period,
3840
)
@@ -41,7 +43,7 @@
4143
from quant_platform_kit.common.execution_outcomes import (
4244
DEFAULT_EXECUTION_BLOCKING_SKIP_REASONS,
4345
filter_execution_blocking_skips,
44-
is_terminal_funding_block,
46+
is_funding_block,
4547
resolve_strategy_run_stage,
4648
)
4749
from quant_platform_kit.common.runtime_inputs import (
@@ -259,6 +261,8 @@ def send_and_capture(text: str) -> bool | None:
259261

260262

261263
def _should_publish_cycle_notification(result: Mapping[str, Any]) -> bool:
264+
if result.get("notification_suppressed_by_policy"):
265+
return False
262266
if result.get("submitted_orders"):
263267
return True
264268
if result.get("error") or result.get("ok") is False:
@@ -513,27 +517,22 @@ def log_message(message: str) -> None:
513517
masked_account = mask_account_id(account)
514518
existing_run = None
515519
if persist_strategy_runs and not settings.dry_run_only:
516-
claim_acquired = claim_live_strategy_run(
520+
existing_run = read_latest_strategy_run_state(
517521
store=store,
518522
account=masked_account,
519523
strategy_profile=strategy_runtime.profile,
520524
run_period=run_period,
521-
now=now,
522525
)
523-
existing_run = read_latest_strategy_run_state(
526+
existing_submission_claim = read_live_strategy_run_claim(
524527
store=store,
525528
account=masked_account,
526529
strategy_profile=strategy_runtime.profile,
527530
run_period=run_period,
528531
)
529-
if not claim_acquired and existing_run is None:
530-
existing_run = {
531-
"stage": "PENDING_SUBMISSION",
532-
"as_of": now.isoformat(),
533-
"claim_only": True,
534-
}
535-
if not claim_acquired or is_duplicate_live_run(existing_run):
536-
duplicate_stage = str(existing_run.get("stage") or "NO_ACTION")
532+
claim_blocks_repeat = has_effective_live_submission_claim(existing_submission_claim)
533+
if claim_blocks_repeat or is_duplicate_live_run(existing_run):
534+
existing_state = dict(existing_run or {})
535+
duplicate_stage = str(existing_state.get("stage") or "PENDING_SUBMISSION")
537536
duplicate_skipped_orders = [
538537
{
539538
"reason": "duplicate_live_strategy_run",
@@ -559,17 +558,18 @@ def log_message(message: str) -> None:
559558
now=now,
560559
)
561560
duplicate_state["idempotency_skipped"] = True
562-
duplicate_state["existing_strategy_run_stage"] = existing_run.get("stage")
563-
duplicate_state["existing_strategy_run_as_of"] = existing_run.get("as_of")
564-
try:
565-
strategy_run_persisted = persist_strategy_run_state(
566-
store=store,
567-
state=duplicate_state,
568-
now=now,
569-
)
570-
except Exception as exc:
571-
strategy_run_persisted = False
572-
strategy_run_persistence_error = f"{type(exc).__name__}: {exc}"
561+
duplicate_state["existing_strategy_run_stage"] = existing_state.get("stage")
562+
duplicate_state["existing_strategy_run_as_of"] = existing_state.get("as_of")
563+
if not claim_blocks_repeat:
564+
try:
565+
strategy_run_persisted = persist_strategy_run_state(
566+
store=store,
567+
state=duplicate_state,
568+
now=now,
569+
)
570+
except Exception as exc:
571+
strategy_run_persisted = False
572+
strategy_run_persistence_error = f"{type(exc).__name__}: {exc}"
573573
result = {
574574
"ok": True,
575575
"api_kind": "unofficial-reverse-engineered",
@@ -583,8 +583,9 @@ def log_message(message: str) -> None:
583583
"strategy_run_stage": duplicate_stage,
584584
"strategy_run_persisted": strategy_run_persisted,
585585
"idempotency_skipped": True,
586-
"existing_strategy_run_stage": existing_run.get("stage"),
587-
"existing_strategy_run_as_of": existing_run.get("as_of"),
586+
"submission_claim_blocks_repeat": claim_blocks_repeat,
587+
"existing_strategy_run_stage": existing_state.get("stage"),
588+
"existing_strategy_run_as_of": existing_state.get("as_of"),
588589
"submitted_orders": [],
589590
"skipped_orders": duplicate_skipped_orders,
590591
"action_done": False,
@@ -636,6 +637,22 @@ def log_message(message: str) -> None:
636637
except Exception as exc:
637638
strategy_run_persisted = False
638639
strategy_run_persistence_error = f"{type(exc).__name__}: {exc}"
640+
641+
submission_claim_acquired = False
642+
643+
def acquire_submission_claim() -> bool:
644+
nonlocal submission_claim_acquired
645+
if submission_claim_acquired:
646+
return True
647+
submission_claim_acquired = claim_live_strategy_run(
648+
store=store,
649+
account=masked_account,
650+
strategy_profile=strategy_runtime.profile,
651+
run_period=run_period,
652+
now=now,
653+
)
654+
return submission_claim_acquired
655+
639656
execution_result = execute_value_target_plan(
640657
plan=plan,
641658
market_data_port=market_data_port,
@@ -649,24 +666,80 @@ def log_message(message: str) -> None:
649666
cash_only_execution=settings.cash_only_execution,
650667
notional_buy_execution=notional_buy_execution_enabled(settings.strategy_profile),
651668
fetch_order_status=lambda broker_order_id: client.get_order_status(account, broker_order_id),
669+
before_live_submission=(
670+
acquire_submission_claim
671+
if persist_strategy_runs and not settings.dry_run_only
672+
else None
673+
),
652674
)
675+
if execution_result.idempotency_blocked:
676+
existing_run = read_latest_strategy_run_state(
677+
store=store,
678+
account=masked_account,
679+
strategy_profile=strategy_runtime.profile,
680+
run_period=run_period,
681+
) or {
682+
"stage": "PENDING_SUBMISSION",
683+
"as_of": now.isoformat(),
684+
"claim_only": True,
685+
}
686+
result = {
687+
"ok": True,
688+
"api_kind": "unofficial-reverse-engineered",
689+
"account": account,
690+
"strategy_profile": strategy_runtime.profile,
691+
"strategy_display_name": strategy_runtime.display_name,
692+
"dry_run_only": settings.dry_run_only,
693+
"live_trading_enabled": settings.live_trading_enabled,
694+
"session_reused": bool(getattr(client, "session_reused", False)),
695+
"strategy_run_period": run_period,
696+
"strategy_run_stage": str(existing_run.get("stage") or "PENDING_SUBMISSION"),
697+
"strategy_run_persisted": strategy_run_persisted,
698+
"idempotency_skipped": True,
699+
"existing_strategy_run_stage": existing_run.get("stage"),
700+
"existing_strategy_run_as_of": existing_run.get("as_of"),
701+
"submitted_orders": [],
702+
"skipped_orders": list(execution_result.skipped_orders),
703+
"action_done": False,
704+
**empty_strategy_plugin_alert_report_fields(),
705+
}
706+
if strategy_run_persistence_error:
707+
result["strategy_run_persistence_error"] = strategy_run_persistence_error
708+
return attach_strategy_plugin_result(
709+
result,
710+
signals=strategy_plugin_signals,
711+
error=strategy_plugin_error,
712+
translator=translator,
713+
)
653714
submitted_orders = list(execution_result.submitted_orders)
654715
skipped_orders = list(execution_result.skipped_orders)
655716
execution_notes = list(execution_result.execution_notes)
717+
decision_diagnostics = dict(getattr(evaluation.decision, "diagnostics", {}) or {})
718+
strategy_funding_shortfall = (
719+
not submitted_orders
720+
and str(
721+
decision_diagnostics.get("dca_skip_reason")
722+
or decision_diagnostics.get("skip_reason")
723+
or ""
724+
).strip().lower()
725+
== "insufficient_cash"
726+
)
727+
if strategy_funding_shortfall:
728+
skipped_orders.append({"reason": "insufficient_cash"})
656729
blocking_skips = filter_execution_blocking_skips(
657730
skipped_orders,
658731
blocking_reasons=BROKER_EXECUTION_BLOCKING_SKIP_REASONS,
659732
)
660733
execution_blocked = bool(blocking_skips)
661-
funding_blocked = is_terminal_funding_block(blocking_skips)
662-
terminal_funding_block = funding_blocked and not execution_result.action_done
734+
funding_blocked = is_funding_block(blocking_skips)
735+
funding_block = funding_blocked and not execution_result.action_done
663736
strategy_run_stage = (
664737
"PENDING_RECONCILIATION"
665738
if execution_result.pending_reconciliation
666739
else resolve_strategy_run_stage(
667740
dry_run_only=settings.dry_run_only,
668741
execution_blocked=execution_blocked,
669-
terminal_funding_block=terminal_funding_block,
742+
terminal_funding_block=funding_block,
670743
action_done=execution_result.action_done,
671744
)
672745
)
@@ -712,11 +785,14 @@ def log_message(message: str) -> None:
712785
}
713786
if execution_blocked:
714787
result["execution_blocked"] = True
715-
result["execution_block_retryable"] = not terminal_funding_block
788+
result["execution_block_retryable"] = not submission_claim_acquired
716789
result["execution_blocking_skips"] = blocking_skips
717790
result["error"] = "Strategy execution blocked; see execution_blocking_skips."
718791
if funding_blocked:
719792
result["funding_blocked"] = True
793+
if str((existing_run or {}).get("stage") or "").upper() == "FUNDING_BLOCKED":
794+
result["notification_suppressed_by_policy"] = True
795+
result["notification_suppressed_reason"] = "repeat_funding_blocked"
720796
if strategy_run_persistence_error:
721797
result["strategy_run_persistence_error"] = strategy_run_persistence_error
722798
if strategy_plugin_alert_result is not None:
@@ -744,9 +820,9 @@ def log_message(message: str) -> None:
744820
portfolio_snapshot=plan.get("portfolio", {}),
745821
evaluation_metadata=getattr(evaluation, "metadata", None),
746822
plan=plan,
747-
submitted_orders=list(execution_result.submitted_orders),
748-
skipped_orders=list(execution_result.skipped_orders),
749-
execution_notes=list(execution_result.execution_notes),
823+
submitted_orders=submitted_orders,
824+
skipped_orders=skipped_orders,
825+
execution_notes=execution_notes,
750826
action_done=execution_result.action_done,
751827
broker_submission_done=execution_result.broker_submission_done,
752828
execution_status=result["execution_status"],
@@ -777,7 +853,7 @@ def log_message(message: str) -> None:
777853
elif send_cycle_notification:
778854
result["notification_sent"] = False
779855
result["notification_suppressed"] = True
780-
result["notification_suppressed_reason"] = "no_trade_or_error"
856+
result.setdefault("notification_suppressed_reason", "no_trade_or_error")
781857
else:
782858
result["notification_sent"] = False
783859
result["notification_suppressed"] = True

0 commit comments

Comments
 (0)