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
20 changes: 10 additions & 10 deletions src/quant_platform_kit/common/execution_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
@dataclass(frozen=True)
class ValueTargetPortfolioInputs:
market_values: Mapping[str, float]
quantities: Mapping[str, int]
quantities: Mapping[str, float]
total_equity: float
liquid_cash: float
sellable_quantities: Mapping[str, int] | None = None
sellable_quantities: Mapping[str, float] | None = None


def build_value_target_portfolio_inputs_from_snapshot(
Expand All @@ -34,24 +34,24 @@ def build_value_target_portfolio_inputs_from_snapshot(
) -> ValueTargetPortfolioInputs:
metadata = getattr(snapshot, "metadata", {}) or {}
raw_sellable_quantities = metadata.get("sellable_quantities") if isinstance(metadata, Mapping) else None
resolved_sellable_quantities: dict[str, int] = {}
resolved_sellable_quantities: dict[str, float] = {}
if isinstance(raw_sellable_quantities, Mapping):
resolved_sellable_quantities = {
str(symbol): int(quantity)
str(symbol): float(quantity)
for symbol, quantity in raw_sellable_quantities.items()
}
market_values: dict[str, float] = {}
quantities: dict[str, int] = {}
sellable_quantities: dict[str, int] | None = (
quantities: dict[str, float] = {}
sellable_quantities: dict[str, float] | None = (
{} if include_sellable_quantities else None
)
for position in getattr(snapshot, "positions", ()) or ():
symbol = str(position.symbol)
quantity = int(position.quantity)
quantity = float(position.quantity)
market_values[symbol] = float(position.market_value)
quantities[symbol] = quantity
if sellable_quantities is not None:
sellable_quantities[symbol] = int(resolved_sellable_quantities.get(symbol, quantity))
sellable_quantities[symbol] = float(resolved_sellable_quantities.get(symbol, quantity))

resolved_liquid_cash = liquid_cash
if resolved_liquid_cash is None:
Expand All @@ -77,7 +77,7 @@ def build_value_target_portfolio_inputs_from_account_state(
sellable_quantities = None
if isinstance(raw_sellable_quantities, Mapping):
sellable_quantities = {
str(symbol): int(quantity)
str(symbol): float(quantity)
for symbol, quantity in raw_sellable_quantities.items()
}

Expand All @@ -87,7 +87,7 @@ def build_value_target_portfolio_inputs_from_account_state(
for symbol, value in dict(account_state["market_values"]).items()
},
quantities={
str(symbol): int(quantity)
str(symbol): float(quantity)
for symbol, quantity in dict(account_state["quantities"]).items()
},
total_equity=float(account_state["total_strategy_equity"]),
Expand Down
42 changes: 42 additions & 0 deletions src/quant_platform_kit/common/quantity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

from decimal import Decimal, InvalidOperation, ROUND_DOWN


def normalize_quantity_step(quantity_step: float | int | str | None) -> Decimal:
try:
step = Decimal(str(quantity_step if quantity_step is not None else "1"))
except (InvalidOperation, ValueError):
step = Decimal("1")
if step <= 0:
return Decimal("1")
return step


def floor_to_quantity_step(
quantity: float | int | str | Decimal,
quantity_step: float | int | str | Decimal | None,
) -> float:
step = normalize_quantity_step(quantity_step)
try:
value = Decimal(str(quantity))
except (InvalidOperation, ValueError):
return 0.0
if value <= 0:
return 0.0
units = (value / step).to_integral_value(rounding=ROUND_DOWN)
return float(units * step)


def normalize_order_quantity(quantity: float | int | str | Decimal) -> int | float:
value = float(quantity or 0.0)
if value.is_integer():
return int(value)
return value


def format_quantity(quantity: float | int | str | Decimal) -> str:
value = normalize_order_quantity(quantity)
if isinstance(value, int):
return str(value)
return f"{value:g}"
24 changes: 12 additions & 12 deletions src/quant_platform_kit/common/runtime_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,10 @@ def build_account_state_from_portfolio_snapshot(
) -> dict[str, Any]:
metadata = getattr(snapshot, "metadata", {}) or {}
raw_sellable_quantities = metadata.get("sellable_quantities") if isinstance(metadata, Mapping) else None
resolved_sellable_quantities: dict[str, int] = {}
resolved_sellable_quantities: dict[str, float] = {}
if isinstance(raw_sellable_quantities, Mapping):
resolved_sellable_quantities = {
str(symbol).strip().upper(): int(quantity)
str(symbol).strip().upper(): float(quantity)
for symbol, quantity in raw_sellable_quantities.items()
if str(symbol).strip()
}
Expand All @@ -117,25 +117,25 @@ def build_account_state_from_portfolio_snapshot(

if filter_enabled:
market_values = {symbol: 0.0 for symbol in normalized_symbols}
quantities = {symbol: 0 for symbol in normalized_symbols}
sellable_quantities = {symbol: 0 for symbol in normalized_symbols}
quantities = {symbol: 0.0 for symbol in normalized_symbols}
sellable_quantities = {symbol: 0.0 for symbol in normalized_symbols}
else:
market_values: dict[str, float] = {}
quantities: dict[str, int] = {}
sellable_quantities: dict[str, int] = {}
quantities: dict[str, float] = {}
sellable_quantities: dict[str, float] = {}

for position in getattr(snapshot, "positions", ()) or ():
symbol = str(position.symbol).strip().upper()
if filter_enabled and symbol not in market_values:
continue
if symbol not in market_values:
market_values[symbol] = 0.0
quantities[symbol] = 0
sellable_quantities[symbol] = 0
quantities[symbol] = 0.0
sellable_quantities[symbol] = 0.0

quantity = int(position.quantity)
quantity = float(position.quantity)
quantities[symbol] = quantity
sellable_quantities[symbol] = int(resolved_sellable_quantities.get(symbol, quantity))
sellable_quantities[symbol] = float(resolved_sellable_quantities.get(symbol, quantity))
market_values[symbol] = float(position.market_value)

resolved_liquid_cash = liquid_cash
Expand Down Expand Up @@ -179,7 +179,7 @@ def build_portfolio_snapshot_from_account_state(

positions: list[Position] = []
for symbol in symbols:
quantity = int(quantities.get(symbol, 0))
quantity = float(quantities.get(symbol, 0.0))
market_value = float(market_values.get(symbol, 0.0))
if quantity <= 0 and market_value <= 0.0:
continue
Expand Down Expand Up @@ -208,7 +208,7 @@ def build_portfolio_snapshot_from_account_state(
raw_sellable_quantities = account_state.get("sellable_quantities")
if isinstance(raw_sellable_quantities, Mapping):
sellable_quantities = {
str(symbol).strip().upper(): int(quantity)
str(symbol).strip().upper(): float(quantity)
for symbol, quantity in raw_sellable_quantities.items()
if str(symbol).strip()
}
Expand Down
12 changes: 6 additions & 6 deletions src/quant_platform_kit/common/strategy_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ class ValueTargetPortfolioPlan:
strategy_symbols: tuple[str, ...]
portfolio_rows: tuple[tuple[str, ...], ...]
market_values: Mapping[str, float]
quantities: Mapping[str, int]
sellable_quantities: Mapping[str, int] | None
quantities: Mapping[str, float]
sellable_quantities: Mapping[str, float] | None
total_equity: float
liquid_cash: float
cash_sweep_symbol: str | None = None
Expand Down Expand Up @@ -791,10 +791,10 @@ def build_value_target_portfolio_plan(
execution_plan: ValueTargetExecutionPlan,
*,
market_values: Mapping[str, float],
quantities: Mapping[str, int],
quantities: Mapping[str, float],
total_equity: float,
liquid_cash: float,
sellable_quantities: Mapping[str, int] | None = None,
sellable_quantities: Mapping[str, float] | None = None,
strategy_symbols_order: str = "risk_safe_income",
portfolio_rows_layout: tuple[str, ...] = ("risk_safe", "income"),
) -> ValueTargetPortfolioPlan:
Expand Down Expand Up @@ -838,14 +838,14 @@ def build_value_target_portfolio_plan(
for symbol in strategy_symbols
}
normalized_quantities = {
symbol: int(quantities.get(symbol, 0))
symbol: float(quantities.get(symbol, 0.0))
for symbol in strategy_symbols
}
normalized_sellable_quantities = (
None
if sellable_quantities is None
else {
symbol: int(sellable_quantities.get(symbol, 0))
symbol: float(sellable_quantities.get(symbol, 0.0))
for symbol in strategy_symbols
}
)
Expand Down
6 changes: 3 additions & 3 deletions src/quant_platform_kit/longbridge/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def estimate_max_purchase_quantity(
*,
order_kind: str,
ref_price: float,
) -> int:
) -> float:
from longport.openapi import OrderSide, OrderType

order_type = OrderType.LO if order_kind == "limit" else OrderType.MO
Expand All @@ -23,7 +23,7 @@ def estimate_max_purchase_quantity(
price=Decimal(str(ref_price)),
)
cash_max_qty = getattr(response, "cash_max_qty", 0)
return max(0, int(Decimal(str(cash_max_qty or "0"))))
return max(0.0, float(Decimal(str(cash_max_qty or "0"))))


def submit_order(
Expand All @@ -32,7 +32,7 @@ def submit_order(
*,
order_kind: str,
side: str,
quantity: int,
quantity: float,
submitted_price: float | None = None,
) -> ExecutionReport:
from longport.openapi import OrderSide, OrderType, TimeInForceType
Expand Down
38 changes: 31 additions & 7 deletions src/quant_platform_kit/longbridge/portfolio.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import Any, Iterable
from typing import Any, Callable, Iterable

from .market_data import fetch_last_price

Expand All @@ -9,6 +9,8 @@ def fetch_strategy_account_state(
q_ctx: Any,
t_ctx: Any,
strategy_assets: Iterable[str],
*,
position_log_fn: Callable[[str], None] | None = None,
) -> dict[str, Any]:
available_cash = 0.0
cash_by_currency: dict[str, float] = {}
Expand All @@ -25,8 +27,8 @@ def fetch_strategy_account_state(

assets = [str(symbol).strip().upper() for symbol in strategy_assets if str(symbol).strip()]
market_values = {symbol: 0.0 for symbol in assets}
quantities = {symbol: 0 for symbol in assets}
sellable_quantities = {symbol: 0 for symbol in assets}
quantities = {symbol: 0.0 for symbol in assets}
sellable_quantities = {symbol: 0.0 for symbol in assets}
filter_enabled = bool(assets)

positions_response = t_ctx.stock_positions()
Expand All @@ -39,19 +41,41 @@ def fetch_strategy_account_state(
continue
if root_symbol not in market_values:
market_values[root_symbol] = 0.0
quantities[root_symbol] = 0
sellable_quantities[root_symbol] = 0
quantities[root_symbol] = 0.0
sellable_quantities[root_symbol] = 0.0

raw_quantity = getattr(position, "quantity", 0)
raw_available_quantity = getattr(position, "available_quantity", raw_quantity)
if raw_quantity is None:
raw_quantity = 0
if raw_available_quantity is None:
raw_available_quantity = raw_quantity
if position_log_fn is not None:
position_log_fn(
"[position_snapshot] raw "
f"symbol={root_symbol} full_symbol={full_symbol} "
f"quantity={raw_quantity} available_quantity={raw_available_quantity}"
)

last_price = fetch_last_price(q_ctx, full_symbol)
if last_price is None:
continue

quantity = int(getattr(position, "quantity", 0))
available_quantity = int(getattr(position, "available_quantity", quantity))
quantity = float(raw_quantity)
available_quantity = float(raw_available_quantity)
market_values[root_symbol] += quantity * last_price
quantities[root_symbol] += quantity
sellable_quantities[root_symbol] += available_quantity

if position_log_fn is not None:
for symbol in assets or tuple(sorted(quantities)):
position_log_fn(
"[position_snapshot] aggregate "
f"symbol={symbol} quantity={quantities.get(symbol, 0.0)} "
f"sellable_quantity={sellable_quantities.get(symbol, 0.0)} "
f"market_value={market_values.get(symbol, 0.0):.2f}"
)

return {
"available_cash": available_cash,
"cash_by_currency": cash_by_currency,
Expand Down
30 changes: 30 additions & 0 deletions tests/test_longbridge_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,36 @@ def test_fetch_strategy_account_state_includes_all_positions_when_assets_empty(s
self.assertEqual(state["sellable_quantities"], {"SOXL": 3, "QQQI": 1})
self.assertEqual(state["total_strategy_equity"], 1190.0)

def test_fetch_strategy_account_state_preserves_fractional_position_quantity(self) -> None:
class FractionalPositionsResponse:
def __init__(self):
self.channels = [FakeChannel([FakePosition("SOXL.US", 1.999999)])]

class FractionalTradeContext(FakeTradeContext):
def stock_positions(self):
return FractionalPositionsResponse()

position_logs = []
state = fetch_strategy_account_state(
FakeQuoteContext(),
FractionalTradeContext(),
["SOXL"],
position_log_fn=position_logs.append,
)

self.assertEqual(state["quantities"]["SOXL"], 1.999999)
self.assertEqual(state["sellable_quantities"]["SOXL"], 1.999999)
self.assertAlmostEqual(state["market_values"]["SOXL"], 99.99995)
self.assertEqual(
position_logs,
[
"[position_snapshot] raw symbol=SOXL full_symbol=SOXL.US quantity=1.999999 "
"available_quantity=1.999999",
"[position_snapshot] aggregate symbol=SOXL quantity=1.999999 "
"sellable_quantity=1.999999 market_value=100.00",
],
)


if __name__ == "__main__":
unittest.main()