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
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,40 @@ jobs:
- name: Checkout
uses: actions/checkout@v6

- name: Resolve QuantPlatformKit ref
id: quant-platform-kit-ref
run: |
set -euo pipefail
ref="main"
if [ -n "${GITHUB_HEAD_REF:-}" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/QuantPlatformKit.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then
ref="${GITHUB_HEAD_REF}"
fi
echo "ref=${ref}" >> "$GITHUB_OUTPUT"

- name: Resolve UsEquityStrategies ref
id: us-equity-strategies-ref
run: |
set -euo pipefail
ref="main"
if [ -n "${GITHUB_HEAD_REF:-}" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/UsEquityStrategies.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then
ref="${GITHUB_HEAD_REF}"
fi
echo "ref=${ref}" >> "$GITHUB_OUTPUT"

- name: Checkout QuantPlatformKit
uses: actions/checkout@v6
with:
repository: QuantStrategyLab/QuantPlatformKit
ref: ${{ steps.quant-platform-kit-ref.outputs.ref }}
path: external/QuantPlatformKit

- name: Checkout UsEquityStrategies
uses: actions/checkout@v6
with:
repository: QuantStrategyLab/UsEquityStrategies
ref: ${{ steps.us-equity-strategies-ref.outputs.ref }}
path: external/UsEquityStrategies

- name: Setup Python
uses: actions/setup-python@v6
with:
Expand All @@ -23,6 +57,7 @@ jobs:
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install ruff
python -m pip install --no-deps -e external/QuantPlatformKit -e external/UsEquityStrategies

- name: Run ruff
run: |
Expand Down
92 changes: 5 additions & 87 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,74 +6,20 @@
import traceback
from datetime import datetime

from strategy.allocation import build_rebalance_plan


def record_skip_log(skip_logs, *, translator, with_prefix, kind, detail):
message = translator(kind, detail=detail)
skip_logs.append(message)
print(with_prefix(message), flush=True)


def resolve_rebalance_plan(
*,
indicators,
account_state,
trend_ma_window,
translator,
cash_reserve_ratio,
min_trade_ratio,
min_trade_floor,
rebalance_threshold_ratio,
small_account_deploy_ratio,
mid_account_deploy_ratio,
large_account_deploy_ratio,
trade_layer_decay_coeff,
income_layer_start_usd,
income_layer_max_ratio,
income_layer_qqqi_weight,
income_layer_spyi_weight,
):
return build_rebalance_plan(
indicators,
account_state,
trend_ma_window=trend_ma_window,
translator=translator,
cash_reserve_ratio=cash_reserve_ratio,
min_trade_ratio=min_trade_ratio,
min_trade_floor=min_trade_floor,
rebalance_threshold_ratio=rebalance_threshold_ratio,
small_account_deploy_ratio=small_account_deploy_ratio,
mid_account_deploy_ratio=mid_account_deploy_ratio,
large_account_deploy_ratio=large_account_deploy_ratio,
trade_layer_decay_coeff=trade_layer_decay_coeff,
income_layer_start_usd=income_layer_start_usd,
income_layer_max_ratio=income_layer_max_ratio,
income_layer_qqqi_weight=income_layer_qqqi_weight,
income_layer_spyi_weight=income_layer_spyi_weight,
)


def run_strategy(
*,
project_id,
secret_name,
trend_ma_window,
token_refresh_threshold_days,
cash_reserve_ratio,
min_trade_ratio,
min_trade_floor,
rebalance_threshold_ratio,
limit_sell_discount,
limit_buy_premium,
small_account_deploy_ratio,
mid_account_deploy_ratio,
large_account_deploy_ratio,
trade_layer_decay_coeff,
income_layer_start_usd,
income_layer_max_ratio,
income_layer_qqqi_weight,
income_layer_spyi_weight,
separator,
translator,
with_prefix,
Expand All @@ -82,8 +28,9 @@ def run_strategy(
fetch_token_from_secret,
refresh_token_if_needed,
build_contexts,
calculate_rotation_indicators,
calculate_strategy_indicators,
fetch_strategy_account_state,
resolve_rebalance_plan,
fetch_last_price,
estimate_max_purchase_quantity,
submit_order_with_alert,
Expand All @@ -102,29 +49,14 @@ def run_strategy(
app_secret = os.getenv("LONGPORT_APP_SECRET", "")
quote_context, trade_context = build_contexts(app_key, app_secret, token)

indicators = calculate_rotation_indicators(quote_context, trend_window=trend_ma_window)
indicators = calculate_strategy_indicators(quote_context)
if indicators is None:
raise Exception("Quote data missing or API limited; cannot compute indicators")

strategy_assets = ["SOXL", "SOXX", "BOXX", "QQQI", "SPYI"]
account_state = fetch_strategy_account_state(quote_context, trade_context, strategy_assets)
account_state = fetch_strategy_account_state(quote_context, trade_context)
plan = resolve_rebalance_plan(
indicators=indicators,
account_state=account_state,
trend_ma_window=trend_ma_window,
translator=translator,
cash_reserve_ratio=cash_reserve_ratio,
min_trade_ratio=min_trade_ratio,
min_trade_floor=min_trade_floor,
rebalance_threshold_ratio=rebalance_threshold_ratio,
small_account_deploy_ratio=small_account_deploy_ratio,
mid_account_deploy_ratio=mid_account_deploy_ratio,
large_account_deploy_ratio=large_account_deploy_ratio,
trade_layer_decay_coeff=trade_layer_decay_coeff,
income_layer_start_usd=income_layer_start_usd,
income_layer_max_ratio=income_layer_max_ratio,
income_layer_qqqi_weight=income_layer_qqqi_weight,
income_layer_spyi_weight=income_layer_spyi_weight,
)

logs = []
Expand Down Expand Up @@ -190,24 +122,10 @@ def run_strategy(
)

if sell_submitted:
account_state = fetch_strategy_account_state(quote_context, trade_context, strategy_assets)
account_state = fetch_strategy_account_state(quote_context, trade_context)
plan = resolve_rebalance_plan(
indicators=indicators,
account_state=account_state,
trend_ma_window=trend_ma_window,
translator=translator,
cash_reserve_ratio=cash_reserve_ratio,
min_trade_ratio=min_trade_ratio,
min_trade_floor=min_trade_floor,
rebalance_threshold_ratio=rebalance_threshold_ratio,
small_account_deploy_ratio=small_account_deploy_ratio,
mid_account_deploy_ratio=mid_account_deploy_ratio,
large_account_deploy_ratio=large_account_deploy_ratio,
trade_layer_decay_coeff=trade_layer_decay_coeff,
income_layer_start_usd=income_layer_start_usd,
income_layer_max_ratio=income_layer_max_ratio,
income_layer_qqqi_weight=income_layer_qqqi_weight,
income_layer_spyi_weight=income_layer_spyi_weight,
)
threshold_value = plan["threshold_value"]
limit_order_symbols = set(plan["limit_order_symbols"])
Expand Down
82 changes: 82 additions & 0 deletions decision_mapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import Any

from quant_platform_kit.strategy_contracts import StrategyDecision


def _target_values(decision: StrategyDecision) -> dict[str, float]:
target_values: dict[str, float] = {}
for position in decision.positions:
if position.target_value is None:
raise ValueError(
"LongBridge decision mapper requires target_value positions; "
f"position {position.symbol!r} is missing target_value"
)
target_values[position.symbol] = float(position.target_value)
return target_values


def _symbols_by_role(decision: StrategyDecision) -> tuple[list[str], list[str], list[str]]:
risk_symbols: list[str] = []
income_symbols: list[str] = []
safe_haven_symbols: list[str] = []
target_values = _target_values(decision)
for position in decision.positions:
if position.role == "safe_haven":
safe_haven_symbols.append(position.symbol)
elif position.role == "income":
income_symbols.append(position.symbol)
else:
risk_symbols.append(position.symbol)
risk_symbols = sorted(dict.fromkeys(risk_symbols))
income_symbols = sorted(
dict.fromkeys(income_symbols),
key=lambda symbol: (-target_values.get(symbol, 0.0), symbol),
)
safe_haven_symbols = sorted(dict.fromkeys(safe_haven_symbols))
return risk_symbols, income_symbols, safe_haven_symbols


def map_strategy_decision_to_plan(
decision: StrategyDecision,
*,
account_state: Mapping[str, Any],
strategy_profile: str,
) -> dict[str, Any]:
diagnostics = dict(decision.diagnostics)
target_values = _target_values(decision)
risk_symbols, income_symbols, safe_haven_symbols = _symbols_by_role(decision)
strategy_assets = tuple(risk_symbols + safe_haven_symbols + income_symbols)
portfolio_rows = tuple(
row
for row in (
tuple(risk_symbols),
tuple(income_symbols),
tuple(safe_haven_symbols),
)
if row
)

return {
"strategy_profile": strategy_profile,
"strategy_assets": strategy_assets,
"limit_order_symbols": tuple(risk_symbols + income_symbols),
"portfolio_rows": portfolio_rows,
"available_cash": float(account_state["available_cash"]),
"market_values": dict(account_state["market_values"]),
"quantities": dict(account_state["quantities"]),
"sellable_quantities": dict(account_state["sellable_quantities"]),
"total_strategy_equity": float(account_state["total_strategy_equity"]),
"current_min_trade": float(diagnostics["current_min_trade"]),
"targets": target_values,
"market_status": diagnostics["market_status"],
"signal_message": diagnostics["signal_message"],
"deploy_ratio_text": diagnostics["deploy_ratio_text"],
"income_ratio_text": diagnostics["income_ratio_text"],
"income_locked_ratio_text": diagnostics["income_locked_ratio_text"],
"active_risk_asset": diagnostics.get("active_risk_asset"),
"investable_cash": float(diagnostics["investable_cash"]),
"threshold_value": float(diagnostics["threshold_value"]),
}
Loading