Skip to content

Commit 3349f66

Browse files
authored
feat(simulation): simulate your portfolio's future (#136)
* feat(simulation): create timeline structure, display worth evolution * refactor: split simulator classes in separate files * fix: ask before fetching from N26 * feat: add default event to apply yearly performance * feat: add autobalance default quarterly event * docs: add docstrings
1 parent 064d126 commit 3349f66

File tree

16 files changed

+613
-65
lines changed

16 files changed

+613
-65
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ Statistics and visualizations will be added soon!
5555
## ✨ Features
5656

5757
1. **✅ Portfolio:** Organize your assets, set targets, and sync with your Finary account.
58-
2. **⏳ Web dashboard:** Generate global statistics and graphs to understand each line and folder.
59-
3. **⏳ Assistant:** Get monthly recommendations on where to invest next to meet your goals.
60-
4. **🔜 Simulator:** Define your life goals and events, simulate your portfolio's future.
58+
2. **✅ Assistant:** Get monthly recommendations on where to invest next to meet your goals.
59+
3. **✅ Simulator:** Define your life goals and events, simulate your portfolio's future.
60+
4. **⏳ Web dashboard:** Generate global statistics and graphs to understand each line and folder.
6161
5. **🙏 Extensions:** Make this tool work for other people's situations, contributions needed 👀
6262

6363
You can check the [current development status](https://github.com/users/MadeInPierre/projects/4). Contributions are warmly welcome!

finalynx/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@
2929
from .dashboard import Dashboard
3030

3131
# Simulator
32-
from .simulator import Simulator
32+
from .simulator import Simulator, Simulation, Timeline
33+
from .simulator import Action, AddLineAmount, SetLineAmount
34+
from .simulator import Event, Salary
35+
from .simulator import DeltaRecurrence, MonthlyRecurrence
36+
from datetime import date
3337

3438
# Main
3539
from .assistant import Assistant

finalynx/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
44
Currently, Finalynx does not support direct calls and only prints the command-line usage description to the console.
55
"""
6-
from docopt import docopt
6+
from docopt import docopt # type: ignore[import]
77

88
from .__meta__ import __version__
99
from .parse.json import ImportJSON
10-
from .usage import __doc__
10+
from .usage import __doc__ # type: ignore[import]
1111

1212
if __name__ == "__main__":
1313
args = docopt(__doc__, version=__version__)

finalynx/assistant.py

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
import os
33
from datetime import date
44
from typing import Any
5+
from typing import Dict
56
from typing import List
67
from typing import Optional
78
from typing import Tuple
8-
from typing import TYPE_CHECKING
99

1010
import finalynx.theme
11-
from docopt import docopt
11+
from docopt import docopt # type: ignore[import]
1212
from finalynx import Dashboard
1313
from finalynx import Fetch
1414
from finalynx import Portfolio
@@ -21,21 +21,20 @@
2121
from finalynx.portfolio.bucket import Bucket
2222
from finalynx.portfolio.envelope import Envelope
2323
from finalynx.portfolio.folder import Sidecar
24-
from html2image import Html2Image
24+
from finalynx.simulator.timeline import Simulation
25+
from finalynx.simulator.timeline import Timeline
26+
from html2image import Html2Image # type: ignore[import]
2527
from rich import inspect # noqa F401
2628
from rich import pretty
2729
from rich import print # noqa F401
2830
from rich import traceback
2931
from rich.columns import Columns
3032
from rich.console import Console
3133
from rich.panel import Panel
34+
from rich.prompt import Confirm
3235
from rich.text import Text
3336
from rich.tree import Tree
3437

35-
36-
if TYPE_CHECKING:
37-
from rich.console import ConsoleRenderable
38-
3938
from .__meta__ import __version__
4039
from .console import console
4140
from .usage import __doc__
@@ -65,9 +64,11 @@ class Assistant:
6564

6665
def __init__(
6766
self,
67+
# Main structure elements
6868
portfolio: Portfolio,
6969
buckets: Optional[List[Bucket]] = None,
7070
envelopes: Optional[List[Envelope]] = None,
71+
# Portfolio options
7172
ignore_orphans: bool = False,
7273
clear_cache: bool = False,
7374
force_signin: bool = False,
@@ -81,15 +82,18 @@ def __init__(
8182
active_sources: Optional[List[str]] = None,
8283
theme: Optional[finalynx.theme.Theme] = None,
8384
sidecars: Optional[List[Sidecar]] = None,
85+
ignore_argv: bool = False,
86+
# Budget options
8487
check_budget: bool = False,
8588
interactive: bool = False,
86-
ignore_argv: bool = False,
89+
# Simulation options
90+
simulation: Optional[Simulation] = None,
8791
):
8892
self.portfolio = portfolio
8993
self.buckets = buckets if buckets else []
9094
self.envelopes = envelopes if envelopes else []
9195

92-
# Options that can either be set in the constructor or from the command line options, type --help
96+
# Options that can either be set in the constructor or from the command line options, see --help
9397
self.ignore_orphans = ignore_orphans
9498
self.clear_cache = clear_cache
9599
self.force_signin = force_signin
@@ -104,6 +108,7 @@ def __init__(
104108
self.sidecars = sidecars if sidecars else []
105109
self.check_budget = check_budget
106110
self.interactive = interactive
111+
self.simulation = simulation
107112

108113
# Set the global color theme if specified
109114
if theme:
@@ -117,6 +122,9 @@ def __init__(
117122
self._fetch = Fetch(self.portfolio, self.clear_cache, self.ignore_orphans)
118123
self.budget = Budget()
119124

125+
# Initialize the simulation timeline with the initial user events
126+
self._timeline = Timeline(simulation, self.portfolio, self.buckets) if simulation else None
127+
120128
def add_source(self, source: SourceBaseLine) -> None:
121129
"""Register a source, either defined in your own config or from the available Finalynx sources
122130
using `from finalynx.fetch.source_any import SourceAny`."""
@@ -204,8 +212,8 @@ def run(self) -> None:
204212

205213
# Render the console elements
206214
main_frame = self.render_mainframe()
207-
panels = self.render_panels()
208-
renders: List[Any] = [main_frame, panels]
215+
dict_panels, render_panels = self.render_panels()
216+
renders: List[Any] = [main_frame, render_panels]
209217
if self.check_budget:
210218
renders.append(self.budget.render_expenses())
211219

@@ -217,9 +225,32 @@ def run(self) -> None:
217225
if self.show_data:
218226
console.print(Panel(fetched_tree, title="Fetched data"))
219227

228+
# Run the simulation if there are events defined
229+
if self._timeline and not self._timeline.is_finished:
230+
console.log(f"Running simulation until {self._timeline.end_date}...")
231+
tree = Tree("\n[bold]Worth", guide_style=TH().TREE_BRANCH)
232+
233+
def append_worth(year: int, amount: float) -> None:
234+
tree.add(f"[{TH().TEXT}]{year}: [{TH().ACCENT}][bold]{round(amount / 1000):>4}[/] k€")
235+
236+
append_worth(date.today().year, self.portfolio.get_amount())
237+
for year in range(date.today().year + 5, self._timeline.end_date.year, 5):
238+
self._timeline.goto(date(year, 1, 1))
239+
append_worth(year, self.portfolio.get_amount())
240+
self._timeline.run()
241+
append_worth(self._timeline.current_date.year, self.portfolio.get_amount())
242+
dict_panels["performance"].add(tree)
243+
244+
console.log(f" Portfolio will be worth [{TH().ACCENT}]{self.portfolio.get_amount():.0f} €[/]")
245+
220246
# Display the entire portfolio and associated recommendations
221247
for render in renders:
222248
console.print("\n\n", render)
249+
console.print("\n")
250+
251+
# TODO replace with a command option
252+
if self._timeline and Confirm.ask(f"Display your future portfolio in {self._timeline.end_date}?"):
253+
console.print("\n\n", self.render_mainframe())
223254

224255
# Interactive review of the budget expenses if enabled
225256
if self.check_budget and self.interactive:
@@ -267,33 +298,37 @@ def render_mainframe(self) -> Columns:
267298

268299
return Columns(main_frame, padding=(0, 0)) # type: ignore
269300

270-
def render_panels(self) -> Columns:
301+
def render_panels(self) -> Tuple[Dict[str, Any], Columns]:
271302
"""Renders the default set of panels used in the default console view when calling run()."""
272303

273304
def panel(title: str, content: Any) -> Panel:
274305
return Panel(content, title=title, padding=(1, 2), expand=False, border_style=TH().PANEL)
275306

276-
# Final set of results to be displayed
277-
panels: List[ConsoleRenderable] = [
278-
Text(" "),
279-
panel("Recommendations", render_recommendations(self.portfolio, self.envelopes)),
280-
panel("Performance", self.render_performance_report()),
281-
]
307+
dict_items: Dict[str, Any] = {
308+
"recommendations": render_recommendations(self.portfolio, self.envelopes),
309+
"performance": self.render_performance_report(),
310+
}
282311

283312
# Add the budget panel if enabled
284313
if self.check_budget:
285-
panels.append(panel("Budget", self.budget.render_summary()))
314+
dict_items["budget"] = self.budget.render_summary()
286315

287-
return Columns(panels, padding=(2, 2))
316+
return dict_items, Columns(
317+
[Text(" ")] + [panel(k.capitalize(), v) for k, v in dict_items.items()], padding=(2, 2)
318+
)
288319

289320
def render_performance_report(self) -> Tree:
290321
"""Print the current and ideal global expected performance. Call either run() or initialize() first."""
291322
perf = self.portfolio.get_perf(ideal=False).expected
292323
perf_ideal = self.portfolio.get_perf(ideal=True).expected
293324

294-
tree = Tree("Global Performance", hide_root=True)
295-
tree.add(f"[{TH().TEXT}]Current: [bold][{TH().ACCENT}]{perf:.1f} %[/] / year")
296-
tree.add(f"[{TH().TEXT}]Planned: [bold][{TH().ACCENT}]{perf_ideal:.1f} %[/] / year")
325+
tree = Tree("Global Performance", hide_root=True, guide_style=TH().TREE_BRANCH)
326+
node = tree.add("[bold]Yield")
327+
node.add(f"[{TH().TEXT}]Current: [bold][{TH().ACCENT}]{perf:.2f} %[/] / year")
328+
node.add(f"[{TH().TEXT}]Planned: [bold][{TH().ACCENT}]{perf_ideal:.2f} %[/] / year")
329+
330+
if self.simulation:
331+
node.add(f"[{TH().TEXT}]Inflation: [bold][gold1]{self.simulation.inflation:.2f} %[/] / year")
297332
return tree
298333

299334
def dashboard(self) -> None:
@@ -351,7 +386,7 @@ def export_img(
351386
# Export the entire portfolio tree to HTML and set the zoom
352387
dashboard_console = Console(record=True, file=open(os.devnull, "w"))
353388
dashboard_console.print(self.render_mainframe())
354-
dashboard_console.print(self.render_panels())
389+
dashboard_console.print(self.render_panels()[1])
355390
output_html = dashboard_console.export_html().replace("body {", f"body {{\n zoom: {zoom};")
356391

357392
# Convert the HTML to PNG

finalynx/budget/budget.py

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Union
66

77
import gspread
8+
from rich.prompt import Confirm
89
from rich.table import Table
910
from rich.tree import Tree
1011

@@ -33,7 +34,7 @@ def __init__(self) -> None:
3334

3435
# Initialize the list of expenses, will be fetched later
3536
self.expenses: List[Expense] = []
36-
self.n_new_expenses: int = -1
37+
self.n_new_expenses: int = 0
3738
self.balance: float = 0.0
3839

3940
# Private copy that only includes expenses that need user review (calculated only once)
@@ -44,11 +45,6 @@ def fetch(self, clear_cache: bool, force_signin: bool = False) -> Tree:
4445
This method also updates the google sheets table with the newly found expenses and
4546
prepares the list of "pending" expenses that need user reviews."""
4647

47-
# Initialize the N26 client with the credentials
48-
source = SourceN26(force_signin)
49-
tree = source.fetch(clear_cache=bool(clear_cache or force_signin))
50-
self.balance = source.balance
51-
5248
# Connect to the Google Sheet that serves as the database of expenses
5349
with console.status(f"[bold {TH().ACCENT}]Connecting to Google Sheets...", spinner_style=TH().ACCENT):
5450
try:
@@ -67,22 +63,30 @@ def fetch(self, clear_cache: bool, force_signin: bool = False) -> Tree:
6763
# Fetch the latest values from the the sheet
6864
sheet_values = self._sheet.get_all_values()
6965

70-
# Get the new expenses from the source that are not in the sheet yet
71-
last_timestamp = max([int(row[0]) for row in sheet_values if str(row[0]).isdigit()])
72-
new_expenses = list(reversed([e for e in source.get_expenses() if e.timestamp > last_timestamp]))
73-
self.n_new_expenses = len(new_expenses)
74-
75-
# Add the new expenses to the sheet
76-
if self.n_new_expenses > 0:
77-
first_empty_row = len(sheet_values) + 1
78-
79-
self._sheet.update(
80-
f"A{first_empty_row}:D{first_empty_row + len(new_expenses)}",
81-
[d.to_list()[:4] for d in new_expenses],
82-
)
66+
# Initialize the N26 client with the credentials
67+
if Confirm.ask("Fetch expenses from N26?", default=True):
68+
source = SourceN26(force_signin)
69+
tree = source.fetch(clear_cache=bool(clear_cache or force_signin))
70+
self.balance = source.balance
71+
72+
# Get the new expenses from the source that are not in the sheet yet
73+
last_timestamp = max([int(row[0]) for row in sheet_values if str(row[0]).isdigit()])
74+
new_expenses = list(reversed([e for e in source.get_expenses() if e.timestamp > last_timestamp]))
75+
self.n_new_expenses = len(new_expenses)
76+
77+
# Add the new expenses to the sheet
78+
if self.n_new_expenses > 0:
79+
first_empty_row = len(sheet_values) + 1
80+
81+
self._sheet.update(
82+
f"A{first_empty_row}:D{first_empty_row + len(new_expenses)}",
83+
[d.to_list()[:4] for d in new_expenses],
84+
)
8385

84-
# Fetch the latest values from the sheet again to get the complete list of expenses
85-
sheet_values = self._fetch_sheet_values() # TODO improve
86+
# Fetch the latest values from the sheet again to get the complete list of expenses
87+
sheet_values = self._fetch_sheet_values() # TODO improve
88+
else:
89+
tree = Tree("N26 Skipped.")
8690

8791
# From now on, we will work with the up-to-date list of expenses
8892
self.expenses = [Expense.from_list(line, i + 2) for i, line in enumerate(sheet_values[1:])]
@@ -97,14 +101,15 @@ def fetch(self, clear_cache: bool, force_signin: bool = False) -> Tree:
97101

98102
def render_expenses(self) -> Union[Table, str]:
99103
# Make sure we are already connected to the source and the sheet
100-
assert self.n_new_expenses > -1, "Call `fetch()` first"
101104
assert self._pending_expenses is not None, "Call `fetch()` first"
102105

103106
# Display the table of pending expenses
104107
n_pending = len(self._pending_expenses)
105108

106109
if n_pending == 0:
107-
return f"[green]No pending expenses 🎉 [dim white]N26 Balance: {self.balance:.2f}\n"
110+
return "[green]No pending expenses 🎉" + (
111+
f" [dim white]N26 Balance: {self.balance:.2f}\n" if self.balance > 0.001 else "\n"
112+
)
108113

109114
return _render_expenses_table(
110115
self._pending_expenses[-Budget.MAX_DISPLAY_ROWS :], # noqa: E203
@@ -119,7 +124,6 @@ def render_summary(self) -> Tree:
119124
"""Render a summary of the budget, mainly the current and previous month's totals."""
120125

121126
# Make sure we are already connected to the source and the sheet
122-
assert self.n_new_expenses > -1, "Call `fetch()` first"
123127
assert self.expenses is not None, "Call `fetch()` first"
124128
tree = Tree("Budget", hide_root=True, guide_style=TH().HINT)
125129

0 commit comments

Comments
 (0)