Skip to content

Commit 663e80b

Browse files
authored
Fix shared broker runtime behavior (#40)
1 parent 2034488 commit 663e80b

10 files changed

Lines changed: 273 additions & 20 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Shared helpers for cash sweep execution flows."""
2+
3+
from __future__ import annotations
4+
5+
import math
6+
from collections.abc import Iterable
7+
8+
9+
def estimate_cash_sweep_sale_quantity_to_fund_buy(
10+
max_quantity: int,
11+
cash_sweep_price: float,
12+
base_buying_power: float,
13+
funding_needs: Iterable[tuple[float, float]],
14+
) -> int:
15+
"""Estimate how much cash sweep symbol to sell to fund the first buy candidate.
16+
17+
The helper keeps platform-specific data fetching outside of shared logic.
18+
Each funding need is provided as ``(underweight_value, ask_price)``.
19+
"""
20+
if max_quantity <= 0:
21+
return 0
22+
sweep_price = float(cash_sweep_price or 0.0)
23+
if sweep_price <= 0.0:
24+
return 0
25+
current_buying_power = max(0.0, float(base_buying_power or 0.0))
26+
27+
for underweight_value, ask_price in funding_needs:
28+
needed_value = float(underweight_value or 0.0)
29+
quote_price = float(ask_price or 0.0)
30+
if needed_value <= 0.0 or quote_price <= 0.0:
31+
continue
32+
max_buy_quantity = int(needed_value // quote_price)
33+
if max_buy_quantity <= 0:
34+
continue
35+
required_buying_power = max_buy_quantity * quote_price
36+
if current_buying_power >= required_buying_power:
37+
return 0
38+
return min(
39+
int(max_quantity),
40+
max(1, math.ceil((required_buying_power - current_buying_power) / sweep_price)),
41+
)
42+
return 0
43+

src/quant_platform_kit/common/runtime_inputs.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from .models import PortfolioSnapshot, Position
99

1010

11+
DEFAULT_SEMICONDUCTOR_ROTATION_HISTORY_LOOKBACK = 420
12+
13+
1114
def _normalize_symbols(strategy_symbols: Iterable[str]) -> tuple[str, ...]:
1215
return tuple(
1316
str(symbol).strip().upper()
@@ -204,6 +207,19 @@ def build_semiconductor_rotation_indicators_from_history(
204207
}
205208

206209

210+
def required_semiconductor_rotation_history_lookback(
211+
*,
212+
trend_ma_window: int = 140,
213+
dynamic_rsi_quantile_window: int = 252,
214+
minimum_lookback: int = DEFAULT_SEMICONDUCTOR_ROTATION_HISTORY_LOOKBACK,
215+
) -> int:
216+
return max(
217+
int(minimum_lookback),
218+
int(trend_ma_window) + 20,
219+
int(dynamic_rsi_quantile_window) + 28,
220+
)
221+
222+
207223
def build_semiconductor_rotation_inputs_from_history(
208224
*,
209225
soxl_history: Iterable[float],

src/quant_platform_kit/ibkr/runtime_inputs.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212
from quant_platform_kit.common.runtime_inputs import (
1313
build_semiconductor_rotation_indicators_from_history,
14+
required_semiconductor_rotation_history_lookback,
1415
)
1516

1617

@@ -73,13 +74,11 @@ def build_semiconductor_rotation_indicators(
7374
historical_close_loader: Callable[..., Any],
7475
*,
7576
trend_ma_window: int = 140,
76-
lookback_buffer: int = 20,
7777
dynamic_rsi_quantile_window: int = 252,
7878
) -> dict[str, dict[str, float]]:
79-
effective_lookback = max(
80-
420,
81-
int(trend_ma_window) + int(lookback_buffer),
82-
int(dynamic_rsi_quantile_window) + int(lookback_buffer) + 90,
79+
effective_lookback = required_semiconductor_rotation_history_lookback(
80+
trend_ma_window=trend_ma_window,
81+
dynamic_rsi_quantile_window=dynamic_rsi_quantile_window,
8382
)
8483
soxl_history = historical_close_loader(
8584
ib,
@@ -106,15 +105,13 @@ def build_semiconductor_rotation_inputs(
106105
historical_close_loader: Callable[..., Any],
107106
*,
108107
trend_ma_window: int = 140,
109-
lookback_buffer: int = 20,
110108
dynamic_rsi_quantile_window: int = 252,
111109
) -> dict[str, dict[str, dict[str, float]]]:
112110
return {
113111
"derived_indicators": build_semiconductor_rotation_indicators(
114112
ib,
115113
historical_close_loader,
116114
trend_ma_window=trend_ma_window,
117-
lookback_buffer=lookback_buffer,
118115
dynamic_rsi_quantile_window=dynamic_rsi_quantile_window,
119116
)
120117
}

src/quant_platform_kit/longbridge/market_data.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from quant_platform_kit.common.runtime_inputs import (
99
build_semiconductor_rotation_indicators_from_history,
10+
required_semiconductor_rotation_history_lookback,
1011
)
1112

1213

@@ -79,7 +80,14 @@ def calculate_rotation_indicators(
7980
) -> dict[str, dict[str, float]] | None:
8081
from longport.openapi import AdjustType, Period
8182

82-
effective_lookback = lookback if lookback is not None else max(280, trend_window + 20, dynamic_rsi_quantile_window + 28)
83+
effective_lookback = (
84+
lookback
85+
if lookback is not None
86+
else required_semiconductor_rotation_history_lookback(
87+
trend_ma_window=trend_window,
88+
dynamic_rsi_quantile_window=dynamic_rsi_quantile_window,
89+
)
90+
)
8391
soxl_bars = q_ctx.candlesticks("SOXL.US", Period.Day, effective_lookback, AdjustType.ForwardAdjust)
8492
soxx_bars = q_ctx.candlesticks("SOXX.US", Period.Day, effective_lookback, AdjustType.ForwardAdjust)
8593
if not soxl_bars or not soxx_bars:

src/quant_platform_kit/longbridge/portfolio.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import time
34
from typing import Any, Callable, Iterable
45

56
from .market_data import fetch_last_prices
@@ -11,19 +12,61 @@ def fetch_strategy_account_state(
1112
strategy_assets: Iterable[str],
1213
*,
1314
position_log_fn: Callable[[str], None] | None = None,
15+
warning_log_fn: Callable[[str], None] | None = None,
1416
) -> dict[str, Any]:
17+
def warn(message: str) -> None:
18+
if warning_log_fn is not None:
19+
warning_log_fn(message)
20+
21+
def load_account_balance() -> tuple[Any, ...]:
22+
attempts = (
23+
("all", {}),
24+
("USD", {"currency": "USD"}),
25+
("HKD", {"currency": "HKD"}),
26+
("CNH", {"currency": "CNH"}),
27+
)
28+
errors: list[str] = []
29+
for label, kwargs in attempts:
30+
for attempt in range(1, 4):
31+
try:
32+
account_balance = t_ctx.account_balance(**kwargs)
33+
except TypeError as exc:
34+
errors.append(f"{label}=TypeError:{exc}")
35+
break
36+
except Exception as exc:
37+
errors.append(f"{label}[attempt={attempt}]={type(exc).__name__}:{exc}")
38+
if attempt < 3:
39+
warn(
40+
"[longbridge_account_balance_retrying] "
41+
f"currency={label} attempt={attempt}/3 error_type={type(exc).__name__}"
42+
)
43+
time.sleep(0.5 * attempt)
44+
continue
45+
break
46+
47+
if kwargs:
48+
warn(f"[longbridge_account_balance_retry_succeeded] currency={label}")
49+
return tuple(account_balance or ())
50+
51+
if errors:
52+
warn("[longbridge_account_balance_failed] " + " | ".join(errors))
53+
return ()
54+
1555
available_cash = 0.0
1656
cash_by_currency: dict[str, float] = {}
17-
account_balance = t_ctx.account_balance()
57+
account_balance = load_account_balance()
1858
for account in account_balance:
59+
account_buy_power = max(0.0, float(getattr(account, "buy_power", 0.0) or 0.0))
60+
account_usd_cash = 0.0
1961
for cash_info in getattr(account, "cash_infos", []):
2062
currency = str(getattr(cash_info, "currency", "") or "").strip().upper()
2163
if not currency:
2264
continue
2365
cash_amount = float(getattr(cash_info, "available_cash", 0.0))
2466
cash_by_currency[currency] = cash_by_currency.get(currency, 0.0) + cash_amount
2567
if currency == "USD":
26-
available_cash += cash_amount
68+
account_usd_cash += cash_amount
69+
available_cash += max(account_buy_power, account_usd_cash)
2770

2871
assets = [str(symbol).strip().upper() for symbol in strategy_assets if str(symbol).strip()]
2972
market_values = {symbol: 0.0 for symbol in assets}
@@ -32,7 +75,25 @@ def fetch_strategy_account_state(
3275
filter_enabled = bool(assets)
3376

3477
position_rows: list[tuple[str, str, Any, Any]] = []
35-
positions_response = t_ctx.stock_positions()
78+
positions_response = None
79+
position_errors: list[str] = []
80+
for attempt in range(1, 4):
81+
try:
82+
positions_response = t_ctx.stock_positions()
83+
break
84+
except Exception as exc:
85+
position_errors.append(f"attempt={attempt} {type(exc).__name__}:{exc}")
86+
if attempt < 3:
87+
warn(
88+
"[longbridge_stock_positions_retrying] "
89+
f"attempt={attempt}/3 error_type={type(exc).__name__}"
90+
)
91+
time.sleep(0.5 * attempt)
92+
continue
93+
warn(
94+
"[longbridge_stock_positions_failed] "
95+
f"errors={' | '.join(position_errors)}"
96+
)
3697
if positions_response and hasattr(positions_response, "channels"):
3798
for channel in positions_response.channels:
3899
for position in getattr(channel, "positions", []):

src/quant_platform_kit/schwab/portfolio.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def fetch_account_snapshot(
2525
balances = account.get("currentBalances", {})
2626
cash_for_equity = float(balances.get("cashAvailableForTrading", 0.0))
2727
raw_withdrawable = float(balances.get("cashAvailableForWithdrawal", 0.0))
28-
buying_power = max(0.0, raw_withdrawable)
28+
buying_power = max(0.0, cash_for_equity)
2929

3030
allowed_symbols = set(strategy_symbols)
3131
positions = []
@@ -47,9 +47,11 @@ def fetch_account_snapshot(
4747
as_of=datetime.utcnow(),
4848
total_equity=total_equity,
4949
buying_power=buying_power,
50+
cash_balance=cash_for_equity,
5051
positions=tuple(positions),
5152
metadata={
5253
"account_hash": account_hash,
5354
"cash_available_for_trading": cash_for_equity,
55+
"cash_available_for_withdrawal": raw_withdrawable,
5456
},
5557
)

tests/test_cash_sweep.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import sys
2+
import unittest
3+
from pathlib import Path
4+
5+
6+
ROOT = Path(__file__).resolve().parents[1]
7+
SRC = ROOT / "src"
8+
if str(SRC) not in sys.path:
9+
sys.path.insert(0, str(SRC))
10+
11+
from quant_platform_kit.common.cash_sweep import estimate_cash_sweep_sale_quantity_to_fund_buy # noqa: E402
12+
13+
14+
class CashSweepHelperTests(unittest.TestCase):
15+
def test_returns_zero_when_buying_power_is_sufficient(self):
16+
qty = estimate_cash_sweep_sale_quantity_to_fund_buy(
17+
10,
18+
100.0,
19+
1000.0,
20+
[(500.0, 10.0)],
21+
)
22+
self.assertEqual(qty, 0)
23+
24+
def test_scales_sale_quantity_to_fund_gap(self):
25+
qty = estimate_cash_sweep_sale_quantity_to_fund_buy(
26+
10,
27+
100.0,
28+
50.0,
29+
[(500.0, 10.0)],
30+
)
31+
self.assertEqual(qty, 5)
32+
33+
def test_ignores_invalid_funding_needs(self):
34+
qty = estimate_cash_sweep_sale_quantity_to_fund_buy(
35+
10,
36+
100.0,
37+
50.0,
38+
[(0.0, 10.0), (500.0, 0.0), (-1.0, -1.0)],
39+
)
40+
self.assertEqual(qty, 0)
41+
42+
43+
if __name__ == "__main__":
44+
unittest.main()

tests/test_longbridge_market_data.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,9 @@ def test_calculate_rotation_indicators(self) -> None:
7171
indicators = calculate_rotation_indicators(FakeQuoteContext(), trend_window=150)
7272

7373
self.assertIsNotNone(indicators)
74-
self.assertEqual(indicators["soxl"]["price"], 379.0)
75-
self.assertEqual(indicators["soxx"]["price"], 479.0)
76-
self.assertAlmostEqual(indicators["soxx"]["ma20"], sum(200.0 + i for i in range(260, 280)) / 20)
74+
self.assertEqual(indicators["soxl"]["price"], 519.0)
75+
self.assertEqual(indicators["soxx"]["price"], 619.0)
76+
self.assertAlmostEqual(indicators["soxx"]["ma20"], sum(200.0 + i for i in range(400, 420)) / 20)
7777
self.assertGreater(indicators["soxx"]["ma20_slope"], 0.0)
7878
self.assertEqual(indicators["soxx"]["rsi14"], 100.0)
7979
self.assertGreaterEqual(indicators["soxx"]["rsi14_dynamic_threshold"], 70.0)

0 commit comments

Comments
 (0)