Skip to content

Commit dfcbe88

Browse files
committed
Support fractional order quantities
1 parent 1822a5c commit dfcbe88

7 files changed

Lines changed: 134 additions & 38 deletions

File tree

src/quant_platform_kit/common/execution_translation.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@
2020
@dataclass(frozen=True)
2121
class ValueTargetPortfolioInputs:
2222
market_values: Mapping[str, float]
23-
quantities: Mapping[str, int]
23+
quantities: Mapping[str, float]
2424
total_equity: float
2525
liquid_cash: float
26-
sellable_quantities: Mapping[str, int] | None = None
26+
sellable_quantities: Mapping[str, float] | None = None
2727

2828

2929
def build_value_target_portfolio_inputs_from_snapshot(
@@ -34,24 +34,24 @@ def build_value_target_portfolio_inputs_from_snapshot(
3434
) -> ValueTargetPortfolioInputs:
3535
metadata = getattr(snapshot, "metadata", {}) or {}
3636
raw_sellable_quantities = metadata.get("sellable_quantities") if isinstance(metadata, Mapping) else None
37-
resolved_sellable_quantities: dict[str, int] = {}
37+
resolved_sellable_quantities: dict[str, float] = {}
3838
if isinstance(raw_sellable_quantities, Mapping):
3939
resolved_sellable_quantities = {
40-
str(symbol): int(quantity)
40+
str(symbol): float(quantity)
4141
for symbol, quantity in raw_sellable_quantities.items()
4242
}
4343
market_values: dict[str, float] = {}
44-
quantities: dict[str, int] = {}
45-
sellable_quantities: dict[str, int] | None = (
44+
quantities: dict[str, float] = {}
45+
sellable_quantities: dict[str, float] | None = (
4646
{} if include_sellable_quantities else None
4747
)
4848
for position in getattr(snapshot, "positions", ()) or ():
4949
symbol = str(position.symbol)
50-
quantity = int(position.quantity)
50+
quantity = float(position.quantity)
5151
market_values[symbol] = float(position.market_value)
5252
quantities[symbol] = quantity
5353
if sellable_quantities is not None:
54-
sellable_quantities[symbol] = int(resolved_sellable_quantities.get(symbol, quantity))
54+
sellable_quantities[symbol] = float(resolved_sellable_quantities.get(symbol, quantity))
5555

5656
resolved_liquid_cash = liquid_cash
5757
if resolved_liquid_cash is None:
@@ -77,7 +77,7 @@ def build_value_target_portfolio_inputs_from_account_state(
7777
sellable_quantities = None
7878
if isinstance(raw_sellable_quantities, Mapping):
7979
sellable_quantities = {
80-
str(symbol): int(quantity)
80+
str(symbol): float(quantity)
8181
for symbol, quantity in raw_sellable_quantities.items()
8282
}
8383

@@ -87,7 +87,7 @@ def build_value_target_portfolio_inputs_from_account_state(
8787
for symbol, value in dict(account_state["market_values"]).items()
8888
},
8989
quantities={
90-
str(symbol): int(quantity)
90+
str(symbol): float(quantity)
9191
for symbol, quantity in dict(account_state["quantities"]).items()
9292
},
9393
total_equity=float(account_state["total_strategy_equity"]),
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
from decimal import Decimal, InvalidOperation, ROUND_DOWN
4+
5+
6+
def normalize_quantity_step(quantity_step: float | int | str | None) -> Decimal:
7+
try:
8+
step = Decimal(str(quantity_step if quantity_step is not None else "1"))
9+
except (InvalidOperation, ValueError):
10+
step = Decimal("1")
11+
if step <= 0:
12+
return Decimal("1")
13+
return step
14+
15+
16+
def floor_to_quantity_step(
17+
quantity: float | int | str | Decimal,
18+
quantity_step: float | int | str | Decimal | None,
19+
) -> float:
20+
step = normalize_quantity_step(quantity_step)
21+
try:
22+
value = Decimal(str(quantity))
23+
except (InvalidOperation, ValueError):
24+
return 0.0
25+
if value <= 0:
26+
return 0.0
27+
units = (value / step).to_integral_value(rounding=ROUND_DOWN)
28+
return float(units * step)
29+
30+
31+
def normalize_order_quantity(quantity: float | int | str | Decimal) -> int | float:
32+
value = float(quantity or 0.0)
33+
if value.is_integer():
34+
return int(value)
35+
return value
36+
37+
38+
def format_quantity(quantity: float | int | str | Decimal) -> str:
39+
value = normalize_order_quantity(quantity)
40+
if isinstance(value, int):
41+
return str(value)
42+
return f"{value:g}"

src/quant_platform_kit/common/runtime_inputs.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,10 @@ def build_account_state_from_portfolio_snapshot(
105105
) -> dict[str, Any]:
106106
metadata = getattr(snapshot, "metadata", {}) or {}
107107
raw_sellable_quantities = metadata.get("sellable_quantities") if isinstance(metadata, Mapping) else None
108-
resolved_sellable_quantities: dict[str, int] = {}
108+
resolved_sellable_quantities: dict[str, float] = {}
109109
if isinstance(raw_sellable_quantities, Mapping):
110110
resolved_sellable_quantities = {
111-
str(symbol).strip().upper(): int(quantity)
111+
str(symbol).strip().upper(): float(quantity)
112112
for symbol, quantity in raw_sellable_quantities.items()
113113
if str(symbol).strip()
114114
}
@@ -117,25 +117,25 @@ def build_account_state_from_portfolio_snapshot(
117117

118118
if filter_enabled:
119119
market_values = {symbol: 0.0 for symbol in normalized_symbols}
120-
quantities = {symbol: 0 for symbol in normalized_symbols}
121-
sellable_quantities = {symbol: 0 for symbol in normalized_symbols}
120+
quantities = {symbol: 0.0 for symbol in normalized_symbols}
121+
sellable_quantities = {symbol: 0.0 for symbol in normalized_symbols}
122122
else:
123123
market_values: dict[str, float] = {}
124-
quantities: dict[str, int] = {}
125-
sellable_quantities: dict[str, int] = {}
124+
quantities: dict[str, float] = {}
125+
sellable_quantities: dict[str, float] = {}
126126

127127
for position in getattr(snapshot, "positions", ()) or ():
128128
symbol = str(position.symbol).strip().upper()
129129
if filter_enabled and symbol not in market_values:
130130
continue
131131
if symbol not in market_values:
132132
market_values[symbol] = 0.0
133-
quantities[symbol] = 0
134-
sellable_quantities[symbol] = 0
133+
quantities[symbol] = 0.0
134+
sellable_quantities[symbol] = 0.0
135135

136-
quantity = int(position.quantity)
136+
quantity = float(position.quantity)
137137
quantities[symbol] = quantity
138-
sellable_quantities[symbol] = int(resolved_sellable_quantities.get(symbol, quantity))
138+
sellable_quantities[symbol] = float(resolved_sellable_quantities.get(symbol, quantity))
139139
market_values[symbol] = float(position.market_value)
140140

141141
resolved_liquid_cash = liquid_cash
@@ -179,7 +179,7 @@ def build_portfolio_snapshot_from_account_state(
179179

180180
positions: list[Position] = []
181181
for symbol in symbols:
182-
quantity = int(quantities.get(symbol, 0))
182+
quantity = float(quantities.get(symbol, 0.0))
183183
market_value = float(market_values.get(symbol, 0.0))
184184
if quantity <= 0 and market_value <= 0.0:
185185
continue
@@ -208,7 +208,7 @@ def build_portfolio_snapshot_from_account_state(
208208
raw_sellable_quantities = account_state.get("sellable_quantities")
209209
if isinstance(raw_sellable_quantities, Mapping):
210210
sellable_quantities = {
211-
str(symbol).strip().upper(): int(quantity)
211+
str(symbol).strip().upper(): float(quantity)
212212
for symbol, quantity in raw_sellable_quantities.items()
213213
if str(symbol).strip()
214214
}

src/quant_platform_kit/common/strategy_contracts.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ class ValueTargetPortfolioPlan:
9999
strategy_symbols: tuple[str, ...]
100100
portfolio_rows: tuple[tuple[str, ...], ...]
101101
market_values: Mapping[str, float]
102-
quantities: Mapping[str, int]
103-
sellable_quantities: Mapping[str, int] | None
102+
quantities: Mapping[str, float]
103+
sellable_quantities: Mapping[str, float] | None
104104
total_equity: float
105105
liquid_cash: float
106106
cash_sweep_symbol: str | None = None
@@ -791,10 +791,10 @@ def build_value_target_portfolio_plan(
791791
execution_plan: ValueTargetExecutionPlan,
792792
*,
793793
market_values: Mapping[str, float],
794-
quantities: Mapping[str, int],
794+
quantities: Mapping[str, float],
795795
total_equity: float,
796796
liquid_cash: float,
797-
sellable_quantities: Mapping[str, int] | None = None,
797+
sellable_quantities: Mapping[str, float] | None = None,
798798
strategy_symbols_order: str = "risk_safe_income",
799799
portfolio_rows_layout: tuple[str, ...] = ("risk_safe", "income"),
800800
) -> ValueTargetPortfolioPlan:
@@ -838,14 +838,14 @@ def build_value_target_portfolio_plan(
838838
for symbol in strategy_symbols
839839
}
840840
normalized_quantities = {
841-
symbol: int(quantities.get(symbol, 0))
841+
symbol: float(quantities.get(symbol, 0.0))
842842
for symbol in strategy_symbols
843843
}
844844
normalized_sellable_quantities = (
845845
None
846846
if sellable_quantities is None
847847
else {
848-
symbol: int(sellable_quantities.get(symbol, 0))
848+
symbol: float(sellable_quantities.get(symbol, 0.0))
849849
for symbol in strategy_symbols
850850
}
851851
)

src/quant_platform_kit/longbridge/execution.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def estimate_max_purchase_quantity(
1212
*,
1313
order_kind: str,
1414
ref_price: float,
15-
) -> int:
15+
) -> float:
1616
from longport.openapi import OrderSide, OrderType
1717

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

2828

2929
def submit_order(
@@ -32,7 +32,7 @@ def submit_order(
3232
*,
3333
order_kind: str,
3434
side: str,
35-
quantity: int,
35+
quantity: float,
3636
submitted_price: float | None = None,
3737
) -> ExecutionReport:
3838
from longport.openapi import OrderSide, OrderType, TimeInForceType

src/quant_platform_kit/longbridge/portfolio.py

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

3-
from typing import Any, Iterable
3+
from typing import Any, Callable, Iterable
44

55
from .market_data import fetch_last_price
66

@@ -9,6 +9,8 @@ def fetch_strategy_account_state(
99
q_ctx: Any,
1010
t_ctx: Any,
1111
strategy_assets: Iterable[str],
12+
*,
13+
position_log_fn: Callable[[str], None] | None = None,
1214
) -> dict[str, Any]:
1315
available_cash = 0.0
1416
cash_by_currency: dict[str, float] = {}
@@ -25,8 +27,8 @@ def fetch_strategy_account_state(
2527

2628
assets = [str(symbol).strip().upper() for symbol in strategy_assets if str(symbol).strip()]
2729
market_values = {symbol: 0.0 for symbol in assets}
28-
quantities = {symbol: 0 for symbol in assets}
29-
sellable_quantities = {symbol: 0 for symbol in assets}
30+
quantities = {symbol: 0.0 for symbol in assets}
31+
sellable_quantities = {symbol: 0.0 for symbol in assets}
3032
filter_enabled = bool(assets)
3133

3234
positions_response = t_ctx.stock_positions()
@@ -39,19 +41,41 @@ def fetch_strategy_account_state(
3941
continue
4042
if root_symbol not in market_values:
4143
market_values[root_symbol] = 0.0
42-
quantities[root_symbol] = 0
43-
sellable_quantities[root_symbol] = 0
44+
quantities[root_symbol] = 0.0
45+
sellable_quantities[root_symbol] = 0.0
46+
47+
raw_quantity = getattr(position, "quantity", 0)
48+
raw_available_quantity = getattr(position, "available_quantity", raw_quantity)
49+
if raw_quantity is None:
50+
raw_quantity = 0
51+
if raw_available_quantity is None:
52+
raw_available_quantity = raw_quantity
53+
if position_log_fn is not None:
54+
position_log_fn(
55+
"[position_snapshot] raw "
56+
f"symbol={root_symbol} full_symbol={full_symbol} "
57+
f"quantity={raw_quantity} available_quantity={raw_available_quantity}"
58+
)
4459

4560
last_price = fetch_last_price(q_ctx, full_symbol)
4661
if last_price is None:
4762
continue
4863

49-
quantity = int(getattr(position, "quantity", 0))
50-
available_quantity = int(getattr(position, "available_quantity", quantity))
64+
quantity = float(raw_quantity)
65+
available_quantity = float(raw_available_quantity)
5166
market_values[root_symbol] += quantity * last_price
5267
quantities[root_symbol] += quantity
5368
sellable_quantities[root_symbol] += available_quantity
5469

70+
if position_log_fn is not None:
71+
for symbol in assets or tuple(sorted(quantities)):
72+
position_log_fn(
73+
"[position_snapshot] aggregate "
74+
f"symbol={symbol} quantity={quantities.get(symbol, 0.0)} "
75+
f"sellable_quantity={sellable_quantities.get(symbol, 0.0)} "
76+
f"market_value={market_values.get(symbol, 0.0):.2f}"
77+
)
78+
5579
return {
5680
"available_cash": available_cash,
5781
"cash_by_currency": cash_by_currency,

tests/test_longbridge_portfolio.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,36 @@ def test_fetch_strategy_account_state_includes_all_positions_when_assets_empty(s
7777
self.assertEqual(state["sellable_quantities"], {"SOXL": 3, "QQQI": 1})
7878
self.assertEqual(state["total_strategy_equity"], 1190.0)
7979

80+
def test_fetch_strategy_account_state_preserves_fractional_position_quantity(self) -> None:
81+
class FractionalPositionsResponse:
82+
def __init__(self):
83+
self.channels = [FakeChannel([FakePosition("SOXL.US", 1.999999)])]
84+
85+
class FractionalTradeContext(FakeTradeContext):
86+
def stock_positions(self):
87+
return FractionalPositionsResponse()
88+
89+
position_logs = []
90+
state = fetch_strategy_account_state(
91+
FakeQuoteContext(),
92+
FractionalTradeContext(),
93+
["SOXL"],
94+
position_log_fn=position_logs.append,
95+
)
96+
97+
self.assertEqual(state["quantities"]["SOXL"], 1.999999)
98+
self.assertEqual(state["sellable_quantities"]["SOXL"], 1.999999)
99+
self.assertAlmostEqual(state["market_values"]["SOXL"], 99.99995)
100+
self.assertEqual(
101+
position_logs,
102+
[
103+
"[position_snapshot] raw symbol=SOXL full_symbol=SOXL.US quantity=1.999999 "
104+
"available_quantity=1.999999",
105+
"[position_snapshot] aggregate symbol=SOXL quantity=1.999999 "
106+
"sellable_quantity=1.999999 market_value=100.00",
107+
],
108+
)
109+
80110

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

0 commit comments

Comments
 (0)