portfolio-rebalancer-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- portfolio_rebalancer_cli-0.1.0/LICENSE +21 -0
- portfolio_rebalancer_cli-0.1.0/PKG-INFO +95 -0
- portfolio_rebalancer_cli-0.1.0/README.md +79 -0
- portfolio_rebalancer_cli-0.1.0/portfolio_rebalancer_cli.egg-info/PKG-INFO +95 -0
- portfolio_rebalancer_cli-0.1.0/portfolio_rebalancer_cli.egg-info/SOURCES.txt +14 -0
- portfolio_rebalancer_cli-0.1.0/portfolio_rebalancer_cli.egg-info/dependency_links.txt +1 -0
- portfolio_rebalancer_cli-0.1.0/portfolio_rebalancer_cli.egg-info/entry_points.txt +2 -0
- portfolio_rebalancer_cli-0.1.0/portfolio_rebalancer_cli.egg-info/requires.txt +1 -0
- portfolio_rebalancer_cli-0.1.0/portfolio_rebalancer_cli.egg-info/top_level.txt +1 -0
- portfolio_rebalancer_cli-0.1.0/pyproject.toml +32 -0
- portfolio_rebalancer_cli-0.1.0/rebalancer/__init__.py +0 -0
- portfolio_rebalancer_cli-0.1.0/rebalancer/engine.py +44 -0
- portfolio_rebalancer_cli-0.1.0/rebalancer/main.py +76 -0
- portfolio_rebalancer_cli-0.1.0/rebalancer/models.py +37 -0
- portfolio_rebalancer_cli-0.1.0/setup.cfg +4 -0
- portfolio_rebalancer_cli-0.1.0/tests/test_engine.py +76 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aaron Cruz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: portfolio-rebalancer-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool for contribution-only portfolio rebalancing.
|
|
5
|
+
Author-email: Aaron Cruz <aaron.m.cruz@rutgers.edu>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/aaronCruise/Portfolio-Rebalancer
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/aaronCruise/Portfolio-Rebalancer/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: pytest>=8.0.0
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Portfolio Rebalancer
|
|
18
|
+
|
|
19
|
+
A CLI tool designed to help investors maintain their target asset allocation through contribution-only rebalancing.
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="https://raw.githubusercontent.com/aaronCruise/Portfolio-Rebalancer/main/images/output_example.png" alt="output_example"/>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
This project was built using **Gemini CLI** to explore AI-driven development for architectural designs, scaffolding, and code generation.
|
|
26
|
+
|
|
27
|
+
## Motivation
|
|
28
|
+
I originally managed my investments and rebalancing through a spreadsheet. I ported the logic to a CLI tool to achieve:
|
|
29
|
+
- Workflow efficiency. Faster execution and a foundation for future automation.
|
|
30
|
+
- Version control. My investment strategy and portfolio can now be tracked with Git.
|
|
31
|
+
- Reliability. All logic lies within modules that are invisible to the user. No more accidental formula breaks!
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
- **Tax-Efficient Logic:** Prioritizes buying underweight assets, never selling.
|
|
35
|
+
- **JSON Portfolios:** Load your custom portfolio from a simple `json` file.
|
|
36
|
+
- **Proportional Scaling:** Handles contributions that are too small to fill all gaps perfectly.
|
|
37
|
+
- **Modern Python:** Built with type hints, dataclasses, and a modular package structure.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
Install the tool directly from PyPI:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install portfolio-rebalancer-cli
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
1. **Configure your portfolio:**
|
|
50
|
+
Create a `portfolio.json` file in your current directory using the following format, adding asset classes and adjusting according to your portfolio:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"assets": [
|
|
55
|
+
{
|
|
56
|
+
"name": "US Total Stock",
|
|
57
|
+
"target_allocation": 0.60,
|
|
58
|
+
"current_balance": 6000.00
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "International Stock",
|
|
62
|
+
"target_allocation": 0.30,
|
|
63
|
+
"current_balance": 2000.00
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "Bond Market",
|
|
67
|
+
"target_allocation": 0.10,
|
|
68
|
+
"current_balance": 2000.00
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
2. **Run the rebalancer:**
|
|
75
|
+
|
|
76
|
+
* **Default Mode** (looks for `portfolio.json` in current folder):
|
|
77
|
+
```bash
|
|
78
|
+
rebalance --contribution 1000
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
* **Custom File Mode**:
|
|
82
|
+
```bash
|
|
83
|
+
rebalance --contribution 1000 --file my_custom_portfolio.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Development & Testing
|
|
87
|
+
|
|
88
|
+
If you want to contribute or run the tests:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git clone https://github.com/aaronCruise/Portfolio-Rebalancer.git
|
|
92
|
+
cd Portfolio-Rebalancer
|
|
93
|
+
pip install -e .
|
|
94
|
+
pytest
|
|
95
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Portfolio Rebalancer
|
|
2
|
+
|
|
3
|
+
A CLI tool designed to help investors maintain their target asset allocation through contribution-only rebalancing.
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src="https://raw.githubusercontent.com/aaronCruise/Portfolio-Rebalancer/main/images/output_example.png" alt="output_example"/>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
This project was built using **Gemini CLI** to explore AI-driven development for architectural designs, scaffolding, and code generation.
|
|
10
|
+
|
|
11
|
+
## Motivation
|
|
12
|
+
I originally managed my investments and rebalancing through a spreadsheet. I ported the logic to a CLI tool to achieve:
|
|
13
|
+
- Workflow efficiency. Faster execution and a foundation for future automation.
|
|
14
|
+
- Version control. My investment strategy and portfolio can now be tracked with Git.
|
|
15
|
+
- Reliability. All logic lies within modules that are invisible to the user. No more accidental formula breaks!
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
- **Tax-Efficient Logic:** Prioritizes buying underweight assets, never selling.
|
|
19
|
+
- **JSON Portfolios:** Load your custom portfolio from a simple `json` file.
|
|
20
|
+
- **Proportional Scaling:** Handles contributions that are too small to fill all gaps perfectly.
|
|
21
|
+
- **Modern Python:** Built with type hints, dataclasses, and a modular package structure.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Install the tool directly from PyPI:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install portfolio-rebalancer-cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
1. **Configure your portfolio:**
|
|
34
|
+
Create a `portfolio.json` file in your current directory using the following format, adding asset classes and adjusting according to your portfolio:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"assets": [
|
|
39
|
+
{
|
|
40
|
+
"name": "US Total Stock",
|
|
41
|
+
"target_allocation": 0.60,
|
|
42
|
+
"current_balance": 6000.00
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "International Stock",
|
|
46
|
+
"target_allocation": 0.30,
|
|
47
|
+
"current_balance": 2000.00
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "Bond Market",
|
|
51
|
+
"target_allocation": 0.10,
|
|
52
|
+
"current_balance": 2000.00
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
2. **Run the rebalancer:**
|
|
59
|
+
|
|
60
|
+
* **Default Mode** (looks for `portfolio.json` in current folder):
|
|
61
|
+
```bash
|
|
62
|
+
rebalance --contribution 1000
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
* **Custom File Mode**:
|
|
66
|
+
```bash
|
|
67
|
+
rebalance --contribution 1000 --file my_custom_portfolio.json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Development & Testing
|
|
71
|
+
|
|
72
|
+
If you want to contribute or run the tests:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git clone https://github.com/aaronCruise/Portfolio-Rebalancer.git
|
|
76
|
+
cd Portfolio-Rebalancer
|
|
77
|
+
pip install -e .
|
|
78
|
+
pytest
|
|
79
|
+
```
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: portfolio-rebalancer-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool for contribution-only portfolio rebalancing.
|
|
5
|
+
Author-email: Aaron Cruz <aaron.m.cruz@rutgers.edu>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/aaronCruise/Portfolio-Rebalancer
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/aaronCruise/Portfolio-Rebalancer/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: pytest>=8.0.0
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Portfolio Rebalancer
|
|
18
|
+
|
|
19
|
+
A CLI tool designed to help investors maintain their target asset allocation through contribution-only rebalancing.
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="https://raw.githubusercontent.com/aaronCruise/Portfolio-Rebalancer/main/images/output_example.png" alt="output_example"/>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
This project was built using **Gemini CLI** to explore AI-driven development for architectural designs, scaffolding, and code generation.
|
|
26
|
+
|
|
27
|
+
## Motivation
|
|
28
|
+
I originally managed my investments and rebalancing through a spreadsheet. I ported the logic to a CLI tool to achieve:
|
|
29
|
+
- Workflow efficiency. Faster execution and a foundation for future automation.
|
|
30
|
+
- Version control. My investment strategy and portfolio can now be tracked with Git.
|
|
31
|
+
- Reliability. All logic lies within modules that are invisible to the user. No more accidental formula breaks!
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
- **Tax-Efficient Logic:** Prioritizes buying underweight assets, never selling.
|
|
35
|
+
- **JSON Portfolios:** Load your custom portfolio from a simple `json` file.
|
|
36
|
+
- **Proportional Scaling:** Handles contributions that are too small to fill all gaps perfectly.
|
|
37
|
+
- **Modern Python:** Built with type hints, dataclasses, and a modular package structure.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
Install the tool directly from PyPI:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install portfolio-rebalancer-cli
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
1. **Configure your portfolio:**
|
|
50
|
+
Create a `portfolio.json` file in your current directory using the following format, adding asset classes and adjusting according to your portfolio:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"assets": [
|
|
55
|
+
{
|
|
56
|
+
"name": "US Total Stock",
|
|
57
|
+
"target_allocation": 0.60,
|
|
58
|
+
"current_balance": 6000.00
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "International Stock",
|
|
62
|
+
"target_allocation": 0.30,
|
|
63
|
+
"current_balance": 2000.00
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "Bond Market",
|
|
67
|
+
"target_allocation": 0.10,
|
|
68
|
+
"current_balance": 2000.00
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
2. **Run the rebalancer:**
|
|
75
|
+
|
|
76
|
+
* **Default Mode** (looks for `portfolio.json` in current folder):
|
|
77
|
+
```bash
|
|
78
|
+
rebalance --contribution 1000
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
* **Custom File Mode**:
|
|
82
|
+
```bash
|
|
83
|
+
rebalance --contribution 1000 --file my_custom_portfolio.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Development & Testing
|
|
87
|
+
|
|
88
|
+
If you want to contribute or run the tests:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git clone https://github.com/aaronCruise/Portfolio-Rebalancer.git
|
|
92
|
+
cd Portfolio-Rebalancer
|
|
93
|
+
pip install -e .
|
|
94
|
+
pytest
|
|
95
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
portfolio_rebalancer_cli.egg-info/PKG-INFO
|
|
5
|
+
portfolio_rebalancer_cli.egg-info/SOURCES.txt
|
|
6
|
+
portfolio_rebalancer_cli.egg-info/dependency_links.txt
|
|
7
|
+
portfolio_rebalancer_cli.egg-info/entry_points.txt
|
|
8
|
+
portfolio_rebalancer_cli.egg-info/requires.txt
|
|
9
|
+
portfolio_rebalancer_cli.egg-info/top_level.txt
|
|
10
|
+
rebalancer/__init__.py
|
|
11
|
+
rebalancer/engine.py
|
|
12
|
+
rebalancer/main.py
|
|
13
|
+
rebalancer/models.py
|
|
14
|
+
tests/test_engine.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytest>=8.0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rebalancer
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "portfolio-rebalancer-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Aaron Cruz", email="aaron.m.cruz@rutgers.edu" },
|
|
10
|
+
]
|
|
11
|
+
description = "A CLI tool for contribution-only portfolio rebalancing."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
license = "MIT"
|
|
19
|
+
license-files = ["LICEN[CS]E*"]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"pytest>=8.0.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
"Homepage" = "https://github.com/aaronCruise/Portfolio-Rebalancer"
|
|
26
|
+
"Bug Tracker" = "https://github.com/aaronCruise/Portfolio-Rebalancer/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
rebalance = "rebalancer.main:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
packages = ["rebalancer"]
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Implements the core math to calculate the recommended contribution amounts."""
|
|
2
|
+
from typing import Dict
|
|
3
|
+
from .models import Portfolio
|
|
4
|
+
|
|
5
|
+
def calculate_rebalance(portfolio: Portfolio, contribution: float) -> Dict[str, float]:
|
|
6
|
+
"""
|
|
7
|
+
Calculates how to distribute a contribution across assets to reach target allocations.
|
|
8
|
+
|
|
9
|
+
Logic:
|
|
10
|
+
1. Calculate the 'Ideal' total value after contribution.
|
|
11
|
+
2. Calculate the 'Target Amount' for each asset based on its target %.
|
|
12
|
+
3. Calculate the 'Gap' (Target Amount - Current Balance).
|
|
13
|
+
4. If the contribution is less than the total gap, distribute it
|
|
14
|
+
proportionally to those gaps.
|
|
15
|
+
"""
|
|
16
|
+
if not portfolio.validate():
|
|
17
|
+
raise ValueError("Portfolio target allocations must sum to 1.0 (100%).")
|
|
18
|
+
|
|
19
|
+
if contribution < 0:
|
|
20
|
+
raise ValueError("Contribution must be a positive number.")
|
|
21
|
+
|
|
22
|
+
new_total_value = portfolio.total_value + contribution
|
|
23
|
+
|
|
24
|
+
# Calculate how much we want to add to each to hit the target
|
|
25
|
+
gaps = {}
|
|
26
|
+
for asset in portfolio.assets:
|
|
27
|
+
target_amount = new_total_value * asset.target_allocation
|
|
28
|
+
gap = target_amount - asset.current_balance
|
|
29
|
+
gaps[asset.name] = max(0.0, gap) # Ignore negative gaps (sells)
|
|
30
|
+
|
|
31
|
+
total_gap = sum(gaps.values())
|
|
32
|
+
|
|
33
|
+
# If no contribution calculated, return zeros immediately.
|
|
34
|
+
# This is to avoid division by 0 later.
|
|
35
|
+
if total_gap == 0:
|
|
36
|
+
return {asset.name: 0.0 for asset in portfolio.assets}
|
|
37
|
+
|
|
38
|
+
# If contribution != total_gap, we scale the additions.
|
|
39
|
+
scaling_factor = contribution / total_gap
|
|
40
|
+
|
|
41
|
+
result = {}
|
|
42
|
+
for name, gap in gaps.items():
|
|
43
|
+
result[name] = round(gap * scaling_factor, 2)
|
|
44
|
+
return result
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defines the entry point for the Portfolio Rebalancer CLI tool.
|
|
3
|
+
|
|
4
|
+
This module parses command-line arguments, initializes configuration,
|
|
5
|
+
and hands off execution to the appropriate functions.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
from .models import Portfolio
|
|
11
|
+
from .engine import calculate_rebalance
|
|
12
|
+
|
|
13
|
+
DEFAULT_PF_PATH = "portfolio.json"
|
|
14
|
+
|
|
15
|
+
def load_portfolio(file_path: str | None) -> Portfolio:
|
|
16
|
+
"""Loads a portfolio from a JSON file, or a default if no path is given."""
|
|
17
|
+
file_to_load = file_path if file_path is not None else DEFAULT_PF_PATH
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
with open(file_to_load, 'r') as f:
|
|
21
|
+
data = json.load(f)
|
|
22
|
+
return Portfolio.from_dict(data)
|
|
23
|
+
except FileNotFoundError:
|
|
24
|
+
print(f"\nError: Portfolio file '{file_to_load}' not found.", file=sys.stderr)
|
|
25
|
+
print("\nTo get started:", file=sys.stderr)
|
|
26
|
+
print(f"1. Create a '{file_to_load}' file in your current directory.", file=sys.stderr)
|
|
27
|
+
print("2. Or use the --file flag to point to an existing JSON file.", file=sys.stderr)
|
|
28
|
+
print("\nSee the README for a portfolio.json template.", file=sys.stderr)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
print(f"Error: The file '{file_to_load}' is not a valid JSON file.", file=sys.stderr)
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
except KeyError as e:
|
|
34
|
+
print(f"Error: Missing expected key {e} in '{file_to_load}'.", file=sys.stderr)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main():
|
|
39
|
+
"""The main entry point for the CLI."""
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
description="A CLI tool for contribution-only portfolio rebalancing."
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--contribution",
|
|
45
|
+
type=float,
|
|
46
|
+
required=True,
|
|
47
|
+
help="The dollar amount you are contributing."
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--file",
|
|
51
|
+
type=str,
|
|
52
|
+
help="Optional: Path to your portfolio JSON file. Defaults to 'portfolio.json'."
|
|
53
|
+
)
|
|
54
|
+
args = parser.parse_args()
|
|
55
|
+
|
|
56
|
+
portfolio = load_portfolio(args.file)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
recommendations = calculate_rebalance(portfolio, args.contribution)
|
|
60
|
+
# Display results
|
|
61
|
+
print("\n" + "="*40)
|
|
62
|
+
print(" PORTFOLIO REBALANCE REPORT")
|
|
63
|
+
print("="*40)
|
|
64
|
+
print(f"Current Value: ${portfolio.total_value:,.2f}")
|
|
65
|
+
print(f"Contribution: ${args.contribution:,.2f}")
|
|
66
|
+
print("-" * 40)
|
|
67
|
+
for name, amount in recommendations.items():
|
|
68
|
+
print(f"{name:<20}: ${amount:>12,.2f}")
|
|
69
|
+
print("-" * 40)
|
|
70
|
+
print("Status: Rebalancing Complete.\n")
|
|
71
|
+
except ValueError as e:
|
|
72
|
+
print(f"Validation Error: {e}", file=sys.stderr)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Defines the classes to represent a portfolio and its components."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class AssetClass:
|
|
6
|
+
name: str
|
|
7
|
+
target_allocation: float # As a decimal (ex, 0.60 for 60%)
|
|
8
|
+
current_balance: float
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Portfolio:
|
|
12
|
+
assets: list[AssetClass] = field(default_factory=list)
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def total_value(self) -> float:
|
|
16
|
+
"""Calculates the total value across all asset classes."""
|
|
17
|
+
return sum(asset.current_balance for asset in self.assets)
|
|
18
|
+
|
|
19
|
+
def validate(self) -> bool:
|
|
20
|
+
"""Ensures the portfolio is valid in that the target allocations sum to 100%."""
|
|
21
|
+
total_allocation = sum(asset.target_allocation for asset in self.assets)
|
|
22
|
+
# Using round to account for floating point math quirks
|
|
23
|
+
return round(total_allocation, 4) == 1.0
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, data: dict) -> "Portfolio":
|
|
27
|
+
"""Creates a Portfolio instance from a dictionary."""
|
|
28
|
+
assets = []
|
|
29
|
+
for dict_item in data.get("assets", []):
|
|
30
|
+
assets.append(
|
|
31
|
+
AssetClass(
|
|
32
|
+
name=dict_item["name"],
|
|
33
|
+
target_allocation=dict_item["target_allocation"],
|
|
34
|
+
current_balance=dict_item["current_balance"]
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
return cls(assets=assets)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Test suite to verify the math implementation in engine.py"""
|
|
2
|
+
import pytest
|
|
3
|
+
from rebalancer.models import AssetClass, Portfolio
|
|
4
|
+
from rebalancer.engine import calculate_rebalance
|
|
5
|
+
|
|
6
|
+
def test_calculate_rebalance_perfect_fit():
|
|
7
|
+
"""
|
|
8
|
+
Test case: We have enough contribution to reach the exact target.
|
|
9
|
+
Portfolio: $900 total. Target: 50/50 ($500/$500).
|
|
10
|
+
Current: A=$400, B=$500.
|
|
11
|
+
Contribution: $100.
|
|
12
|
+
Expected: Add $100 to A, $0 to B.
|
|
13
|
+
"""
|
|
14
|
+
# Arrange
|
|
15
|
+
a = AssetClass("A", 0.5, 400.0)
|
|
16
|
+
b = AssetClass("B", 0.5, 500.0)
|
|
17
|
+
portfolio = Portfolio([a, b])
|
|
18
|
+
contribution = 100.0
|
|
19
|
+
|
|
20
|
+
# Act
|
|
21
|
+
result = calculate_rebalance(portfolio, contribution)
|
|
22
|
+
|
|
23
|
+
# Assert
|
|
24
|
+
assert result["A"] == 100.0
|
|
25
|
+
assert result["B"] == 0.0
|
|
26
|
+
|
|
27
|
+
def test_calculate_rebalance_proportional_scaling():
|
|
28
|
+
"""
|
|
29
|
+
Test case: Contribution is NOT enough to fill the gap.
|
|
30
|
+
Portfolio: $800 total. Target: 50/50 ($400/$400)
|
|
31
|
+
Current: A=$400, B=$400.
|
|
32
|
+
Contribution: $50.
|
|
33
|
+
Expected: Both get 50% of their gap ($25 each).
|
|
34
|
+
"""
|
|
35
|
+
# Arrange
|
|
36
|
+
a = AssetClass("A", 0.5, 400.0)
|
|
37
|
+
b = AssetClass("B", 0.5, 400.0)
|
|
38
|
+
portfolio = Portfolio([a, b])
|
|
39
|
+
contribution = 50.0
|
|
40
|
+
|
|
41
|
+
# Act
|
|
42
|
+
result = calculate_rebalance(portfolio, contribution)
|
|
43
|
+
|
|
44
|
+
# Assert
|
|
45
|
+
assert result["A"] == 25.0
|
|
46
|
+
assert result["B"] == 25.0
|
|
47
|
+
|
|
48
|
+
def test_zero_contribution():
|
|
49
|
+
"""
|
|
50
|
+
Test case: Contribution is $0.
|
|
51
|
+
Portfolio: $800 total. Target: 50/50 ($400/$400)
|
|
52
|
+
Current: A=$400, B=$400.
|
|
53
|
+
Contribution: $0
|
|
54
|
+
Expected: Add $0 each.
|
|
55
|
+
"""
|
|
56
|
+
# Arrange
|
|
57
|
+
a = AssetClass("A", 0.5, 400.0)
|
|
58
|
+
b = AssetClass("B", 0.5, 400.0)
|
|
59
|
+
portfolio = Portfolio([a, b])
|
|
60
|
+
contribution = 0.0
|
|
61
|
+
|
|
62
|
+
# Act
|
|
63
|
+
result = calculate_rebalance(portfolio, contribution)
|
|
64
|
+
|
|
65
|
+
# Assert
|
|
66
|
+
assert result["A"] == 0.0
|
|
67
|
+
assert result["B"] == 0.0
|
|
68
|
+
|
|
69
|
+
def test_invalid_contribution_raises_error():
|
|
70
|
+
"""
|
|
71
|
+
Test case: Negative contribution should raise a ValueError.
|
|
72
|
+
"""
|
|
73
|
+
portfolio = Portfolio([AssetClass("A", 1.0, 100.0)])
|
|
74
|
+
|
|
75
|
+
with pytest.raises(ValueError, match="Contribution must be a positive number"):
|
|
76
|
+
calculate_rebalance(portfolio, -100.0)
|