Skip to content

Commit adf2d1e

Browse files
committed
feat: replace SLSQP solver with MILP (cvxpy+HiGHS) and add fractional asset support
- Rewrite rebalancing_helper.py: drop scipy, use cvxpy MILP with HiGHS backend - Objective: L1 norm on dollar deviations from target (MILP-compatible) - Per-asset integer or continuous variables based on 'fractional' flag - Fix selling_allowed delta-units bug (absolute → delta conversion) - Add 'fractional: bool = False' field to Asset, AssetConfig, loader - Fractional assets use continuous cp.Variable(); others integer-constrained - Mutual funds (0P*.ST tickers) marked fractional in portfolio JSONs - Update schemas.py: float quantity field, integer validator for non-fractional - portfolio.py: fractional-aware display, rename abbreviations for clarity - Bump to v0.4.0, replace scipy with cvxpy>=1.8.2 in dependencies - Add tests: mixed-fractional unit tests, solver validation integration tests
1 parent a7cd8a3 commit adf2d1e

9 files changed

Lines changed: 610 additions & 146 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ authors = [{ name = "Karl Lundgren", email = "k.github@lundgrens.net" }]
1313
dependencies = [
1414
"numpy>=1.26.1",
1515
"requests>=2.31.0",
16-
"scipy>=1.11.3",
16+
"cvxpy>=1.8.2",
1717
"yfinance>=0.2.65",
1818
"pydantic>=2.0",
1919
"rich>=13.0",
@@ -38,7 +38,7 @@ select = ["E", "F", "I", "B", "UP"]
3838
ignore = ["E501"]
3939

4040
[tool.ty.analysis]
41-
allowed-unresolved-imports = ["scipy", "yfinance"]
41+
allowed-unresolved-imports = ["cvxpy", "yfinance"]
4242

4343
[tool.pytest.ini_options]
4444
testpaths = ["rebalance/tests"]

rebalance/asset.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,35 @@ def __init__(
1919
nasdaq_nordic_id=None,
2020
nasdaq_nordic_asset_class=None,
2121
name=None,
22+
fractional=False,
2223
):
2324
"""
2425
Initialization.
2526
2627
Args
2728
----
2829
- ticker (str): Ticker of the asset.
29-
- quantity (int, optional): Number of units of the asset. Default is zero.
30+
- quantity (int or float, optional): Number of units of the asset. Must be an
31+
integer when ``fractional=False`` (the default). Default is zero.
3032
- session (optional): Requests session passed to the Nasdaq Nordic
3133
fetcher. Not used for yfinance (which manages its own session).
3234
- nasdaq_nordic_id (str, optional): Nasdaq Nordic instrument ID (e.g. "TX4856348"). If provided, price is fetched from the Nasdaq Nordic API.
3335
- nasdaq_nordic_asset_class (str, optional): Asset class for Nasdaq Nordic API (e.g. "ETN/ETC", "ETF", "Share"). Required when nasdaq_nordic_id is set.
3436
- name (str, optional): Human-readable name of the asset. Default is None.
37+
- fractional (bool, optional): When True, the asset supports fractional units
38+
(e.g. mutual funds). Default is False.
3539
"""
3640

3741
assert ticker is not None, "ticker symbol is a mandatory argument."
38-
assert isinstance(quantity, int), "quantity must be integer."
42+
assert isinstance(quantity, (int, float)), "quantity must be numeric."
43+
if not fractional:
44+
assert quantity == int(quantity), (
45+
"quantity must be integer for non-fractional assets."
46+
)
3947

4048
self._ticker = ticker
41-
self._quantity = quantity
49+
self._quantity = float(quantity)
50+
self._fractional = fractional
4251
self._name = name
4352

4453
if nasdaq_nordic_id is not None:
@@ -62,15 +71,24 @@ def name(self):
6271
"""(str | None): Human-readable name of the asset."""
6372
return self._name
6473

74+
@property
75+
def fractional(self):
76+
"""(bool): Whether this asset supports fractional units."""
77+
return self._fractional
78+
6579
@property
6680
def quantity(self):
67-
"""(int): Number of units of the asset."""
81+
"""(int | float): Number of units of the asset."""
6882
return self._quantity
6983

7084
@quantity.setter
7185
def quantity(self, quantity):
72-
assert isinstance(quantity, int), "quantity must be integer."
73-
self._quantity = quantity
86+
assert isinstance(quantity, (int, float)), "quantity must be numeric."
87+
if not self._fractional:
88+
assert quantity == int(quantity), (
89+
"quantity must be integer for non-fractional assets."
90+
)
91+
self._quantity = float(quantity)
7492

7593
@property
7694
def price(self):
@@ -163,4 +181,7 @@ def mer(self):
163181
def __str__(self):
164182
# return yf.Ticker(
165183
# self._ticker).info['shortName'] + " (" + self._ticker + ")"
166-
return f"{self._ticker:4s} {self._quantity:4d} {self.market_value():8.2f}"
184+
qty = (
185+
f"{self._quantity:.3f}" if self._fractional else f"{int(self._quantity):4d}"
186+
)
187+
return f"{self._ticker:4s} {qty} {self.market_value():8.2f}"

rebalance/loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def _make_session() -> requests.Session:
2525

2626

2727
def _build_asset(asset_config, session: requests.Session) -> Asset:
28-
kwargs: dict = {"session": session}
28+
kwargs: dict = {"session": session, "fractional": asset_config.fractional}
2929
if asset_config.nasdaq_nordic_id is not None:
3030
kwargs["nasdaq_nordic_id"] = asset_config.nasdaq_nordic_id
3131
kwargs["nasdaq_nordic_asset_class"] = asset_config.nasdaq_nordic_asset_class

rebalance/portfolio.py

Lines changed: 40 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,11 @@ def market_value(self, currency):
173173
float: The total market value of the assets in the portfolio.
174174
"""
175175

176-
mv = 0.0
176+
total = 0.0
177177
for asset in self.assets.values():
178-
mv += asset.market_value_in(currency)
178+
total += asset.market_value_in(currency)
179179

180-
return mv
180+
return total
181181

182182
def cash_value(self, currency):
183183
"""
@@ -190,11 +190,11 @@ def cash_value(self, currency):
190190
float: The total cash value in the portfolio.
191191
"""
192192

193-
cv = 0.0
193+
total = 0.0
194194
for cash in self.cash.values():
195-
cv += cash.amount_in(currency)
195+
total += cash.amount_in(currency)
196196

197-
return cv
197+
return total
198198

199199
def value(self, currency):
200200
"""
@@ -315,15 +315,17 @@ def rebalance(self, target_allocation, verbose=False):
315315

316316
# offload heavy work
317317
(balanced_portfolio, new_units, prices, cost, exchange_history) = (
318-
rebalancing_helper.rebalance(self, target_allocation_np)
318+
rebalancing_helper.rebalance(self, target_allocation_reordered)
319319
)
320320

321321
# compute old and new asset allocation
322322
# and largest diff between new and target asset allocation
323-
old_alloc = self.asset_allocation()
324-
new_alloc = balanced_portfolio.asset_allocation()
325-
max_diff = max(
326-
abs(target_allocation_np - np.fromiter(new_alloc.values(), dtype=float))
323+
old_allocation = self.asset_allocation()
324+
new_allocation = balanced_portfolio.asset_allocation()
325+
largest_discrepancy = max(
326+
abs(
327+
target_allocation_np - np.fromiter(new_allocation.values(), dtype=float)
328+
)
327329
)
328330

329331
if verbose:
@@ -352,17 +354,18 @@ def rebalance(self, target_allocation, verbose=False):
352354

353355
qty = new_units[ticker]
354356
amt = cost[ticker]
357+
qty_fmt = f"{qty:,d}" if isinstance(qty, int) else f"{qty:,.3f}"
355358
if qty > 0:
356-
qty_str = f"[green]{qty:,d}[/green]"
359+
qty_str = f"[green]{qty_fmt}[/green]"
357360
amt_str = f"[green]{amt:,.0f}[/green]"
358361
elif qty < 0:
359-
qty_str = f"[red]{qty:,d}[/red]"
362+
qty_str = f"[red]{qty_fmt}[/red]"
360363
amt_str = f"[red]{amt:,.0f}[/red]"
361364
else:
362-
qty_str = f"[dim]{qty:,d}[/dim]"
365+
qty_str = f"[dim]{qty_fmt}[/dim]"
363366
amt_str = f"[dim]{amt:,.0f}[/dim]"
364367

365-
new_a = new_alloc[ticker]
368+
new_a = new_allocation[ticker]
366369
tgt_a = target_allocation[ticker]
367370
new_alloc_str = (
368371
f"[yellow]{new_a:.2f}[/yellow]"
@@ -381,7 +384,7 @@ def rebalance(self, target_allocation, verbose=False):
381384
qty_str,
382385
amt_str,
383386
prices[ticker][1],
384-
f"{old_alloc[ticker]:.2f}",
387+
f"{old_allocation[ticker]:.2f}",
385388
new_alloc_str,
386389
f"{tgt_a:.2f}",
387390
]
@@ -390,7 +393,7 @@ def rebalance(self, target_allocation, verbose=False):
390393
_console.print()
391394
_console.print(table)
392395
_console.print(
393-
f"Largest discrepancy between new and target allocation: [bold]{max_diff:.2f}%[/bold]"
396+
f"Largest discrepancy between new and target allocation: [bold]{largest_discrepancy:.2f}%[/bold]"
394397
)
395398

396399
if exchange_history:
@@ -418,9 +421,11 @@ def rebalance(self, target_allocation, verbose=False):
418421

419422
# Now that we're done, we can replace old portfolio with the new one
420423
self.__dict__.update(balanced_portfolio.__dict__)
421-
logger.info("Rebalancing complete (largest discrepancy: {:.2f}%)", max_diff)
424+
logger.info(
425+
"Rebalancing complete (largest discrepancy: {:.2f}%)", largest_discrepancy
426+
)
422427

423-
return (new_units, prices, exchange_history, max_diff)
428+
return (new_units, prices, exchange_history, largest_discrepancy)
424429

425430
def _sell_everything(self):
426431
"""
@@ -472,27 +477,27 @@ def _smart_exchange(self, currency_amount):
472477

473478
# first, compute amount we have to convert to and amount we have for conversion
474479

475-
to_conv = {}
476-
from_conv = copy.deepcopy(self.cash)
477-
for curr in currency_amount:
478-
if curr not in self.cash:
479-
from_conv[curr] = Cash(0.00, curr)
480+
to_fund = {}
481+
available = copy.deepcopy(self.cash)
482+
for currency in currency_amount:
483+
if currency not in self.cash:
484+
available[currency] = Cash(0.00, currency)
480485

481-
to = currency_amount[curr] - from_conv[curr].amount
486+
shortfall = currency_amount[currency] - available[currency].amount
482487

483-
if to > 0:
484-
to_conv[curr] = Cash(to, curr)
485-
del from_conv[curr] # no extra cash available for conversion
488+
if shortfall > 0:
489+
to_fund[currency] = Cash(shortfall, currency)
490+
del available[currency] # no extra cash available for conversion
486491
else:
487492
# no conversion will be necessary
488-
from_conv[curr].amount -= currency_amount[curr]
493+
available[currency].amount -= currency_amount[currency]
489494

490495
# perform currency exchange
491496
exchange_history = []
492-
for to_cash in to_conv.values():
493-
one_exchange = False
497+
for to_cash in to_fund.values():
498+
single_source_sufficient = False
494499
# Try converting one shot if possible
495-
for from_cash in from_conv.values():
500+
for from_cash in available.values():
496501
if from_cash.amount_in(to_cash.currency) >= to_cash.amount:
497502
# perform conversion
498503
self.exchange_currency(
@@ -519,14 +524,14 @@ def _smart_exchange(self, currency_amount):
519524
to_cash.amount = 0.00
520525

521526
# move to next 'to_cash'
522-
one_exchange = True
527+
single_source_sufficient = True
523528
break
524529

525530
# If we reached here,
526531
# it means we couldn't perform one currency exchange to meet our 'to_cash'
527532
# So we'll just convert whatever we can
528-
if not one_exchange:
529-
for from_cash in from_conv.values():
533+
if not single_source_sufficient:
534+
for from_cash in available.values():
530535
if from_cash.amount_in(to_cash.currency) >= to_cash.amount:
531536
# perform conversion
532537
self.exchange_currency(

0 commit comments

Comments
 (0)