Skip to content

Commit

Permalink
fix(simulator): show portfolio evolution in dashboard, cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
MadeInPierre committed Aug 10, 2023
1 parent 5802f78 commit 78c1782
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 95 deletions.
2 changes: 1 addition & 1 deletion finalynx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from .dashboard import Dashboard

# Simulator
from .simulator import Simulator, Simulation, Timeline
from .simulator import Simulation, Timeline
from .simulator import Action, AddLineAmount, SetLineAmount
from .simulator import Event, Salary
from .simulator import DeltaRecurrence, MonthlyRecurrence
Expand Down
65 changes: 44 additions & 21 deletions finalynx/assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
from rich.columns import Columns
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Confirm
from rich.text import Text
from rich.tree import Tree

Expand Down Expand Up @@ -188,6 +187,10 @@ def _parse_args(self) -> None:
self.export_dir = args["--export-dir"]
if args["--sources"]:
self.active_sources = str(args["--sources"]).split(",")
if args["--future"] and self.simulation:
self.simulation.print_final = True
if args["--sim-steps"] and self.simulation:
self.simulation.step_years = int(args["--sim-steps"])
if args["--theme"]:
theme_name = str(args["--theme"])
if theme_name not in finalynx.theme.AVAILABLE_THEMES:
Expand Down Expand Up @@ -226,32 +229,23 @@ def run(self) -> None:
console.print(Panel(fetched_tree, title="Fetched data"))

# Run the simulation if there are events defined
if self._timeline and not self._timeline.is_finished:
console.log(f"Running simulation until {self._timeline.end_date}...")
tree = Tree("\n[bold]Worth", guide_style=TH().TREE_BRANCH)

def append_worth(year: int, amount: float) -> None:
tree.add(f"[{TH().TEXT}]{year}: [{TH().ACCENT}][bold]{round(amount / 1000):>4}[/] k€")

append_worth(date.today().year, self.portfolio.get_amount())
for year in range(date.today().year + 5, self._timeline.end_date.year, 5):
self._timeline.goto(date(year, 1, 1))
append_worth(year, self.portfolio.get_amount())
self._timeline.run()
append_worth(self._timeline.current_date.year, self.portfolio.get_amount())
dict_panels["performance"].add(tree)
if self.simulation:
# Add the simulation summary to the performance panel in the console
dict_panels["performance"].add(self.simulate())

console.log(f" Portfolio will be worth [{TH().ACCENT}]{self.portfolio.get_amount():.0f} €[/]")
# If enabled by the user, print the final portfolio after the simulation
if self.simulation.print_final:
renders.append(f"\nYour portfolio in {self.simulation.end_date}:")
renders.append(self.render_mainframe())
else:
end_year = self.simulation.end_date.year if self.simulation.end_date else date.today().year + 100
console.log(f" [bold]Tip:[/] Use --future to display the final portfolio in {end_year}.\n")

# Display the entire portfolio and associated recommendations
for render in renders:
console.print("\n\n", render)
console.print("\n")

# TODO replace with a command option
if self._timeline and Confirm.ask(f"Display your future portfolio in {self._timeline.end_date}?"):
console.print("\n\n", self.render_mainframe())

# Interactive review of the budget expenses if enabled
if self.check_budget and self.interactive:
self.budget.interactive_review()
Expand Down Expand Up @@ -280,6 +274,35 @@ def initialize(self) -> Tree:

return fetched_tree

def simulate(self) -> Tree:
"""Simulate your portfolio's future with the `Simulation` configuration
passed in the `Assistant` constructor.
:returns: A tree with the portfolio total worth every few years until the end date.
"""
if not (self.simulation and self._timeline and not self._timeline.is_finished):
raise ValueError("Nothing to simulate, have you added events?")

console.log(f"Running simulation until {self._timeline.end_date}...")
tree = Tree("\n[bold]Worth", guide_style=TH().TREE_BRANCH)

# Utility function to append a new formatted line to the tree
def append_worth(year: int, amount: float) -> None:
tree.add(f"[{TH().TEXT}]{year}: [{TH().ACCENT}][bold]{round(amount / 1000):>4}[/] k€")

# Run the simulation and append the results to the tree every `step_years`
append_worth(date.today().year, self.portfolio.get_amount())
for year in range(
date.today().year + self.simulation.step_years,
self._timeline.end_date.year,
self.simulation.step_years,
):
self._timeline.goto(date(year, 12, 31))
append_worth(year, self.portfolio.get_amount())
self._timeline.run()
append_worth(self._timeline.current_date.year, self.portfolio.get_amount())
console.log(f" Portfolio will be worth [{TH().ACCENT}]{self.portfolio.get_amount():.0f} €[/]")
return tree

def render_mainframe(self) -> Columns:
"""Renders the main tree and sidecars together. Call either run() or initialize() first."""

Expand Down Expand Up @@ -334,7 +357,7 @@ def render_performance_report(self) -> Tree:
def dashboard(self) -> None:
"""Launch an interactive web dashboard! Call either run() or initialize() first."""
console.log("Launching dashboard.")
Dashboard(hide_amounts=self.hide_amounts).run(portfolio=self.portfolio)
Dashboard(hide_amounts=self.hide_amounts).run(self.portfolio, self._timeline)

def export_json(self, dirpath: str) -> None:
"""Save everything in a JSON file. Can be used for data analysis in future
Expand Down
3 changes: 3 additions & 0 deletions finalynx/budget/budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ def _add_node(title: str, total: float, hint: str = "") -> Tree:

mean_monthly_total = round(sum(month_totals[:-1]) / len(month_totals[:-1]))
last_month_name = datetime(now.year, now.month - 1, 1).strftime("%B")

delta = month_totals[-1] - mean_monthly_total
tree.add(f"[{TH().TEXT}]Current delta [{'green' if delta > 0 else 'red'}][bold]{delta:>12} €[/]\n")
tree.add(
f"[{TH().TEXT}]Mean up to {last_month_name:<9} [{TH().ACCENT}][bold]{mean_monthly_total:>5} €[/] / month"
)
Expand Down
6 changes: 3 additions & 3 deletions finalynx/dashboard/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from finalynx.portfolio.folder import FolderDisplay
from finalynx.portfolio.line import Line
from finalynx.portfolio.node import Node
from finalynx.simulator.simulator import Simulator
from finalynx.simulator.timeline import Timeline # type: ignore[import]
from nicegui import ui

from ..console import console
Expand Down Expand Up @@ -60,7 +60,7 @@ class Dashboard:
def __init__(self, hide_amounts: bool = False):
self.hide_amounts = hide_amounts

def run(self, portfolio: Portfolio) -> None:
def run(self, portfolio: Portfolio, timeline: Optional[Timeline] = None) -> None:
"""Simple structure for now, to be improved!"""
self.color_map = "finalynx"
self.selected_node: Node = portfolio
Expand Down Expand Up @@ -181,7 +181,7 @@ def _on_select_color_map(data: Any) -> None:
)
with ui.row():
self.chart_envelopes = ui.chart(AnalyzeEnvelopes(self.selected_node).chart())
self.chart_simulator = ui.chart(Simulator().chart(portfolio))
self.chart_simulation = ui.chart(timeline.chart() if timeline else {})

ui.run(
title="Finalynx Dashboard",
Expand Down
1 change: 0 additions & 1 deletion finalynx/simulator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,5 @@
from .recurrence import MonthlyRecurrence

# Main classes
from .simulator import Simulator
from .timeline import Timeline
from .timeline import Simulation
67 changes: 0 additions & 67 deletions finalynx/simulator/simulator.py

This file was deleted.

72 changes: 70 additions & 2 deletions finalynx/simulator/timeline.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from dataclasses import dataclass
from datetime import date
from datetime import timedelta
from typing import Any
from typing import Dict
from typing import List
from typing import Optional

from finalynx.analyzer.investment_state import AnalyzeInvestmentStates
from finalynx.config import get_active_theme as TH
from finalynx.console import console
from finalynx.portfolio.bucket import Bucket
from finalynx.portfolio.envelope import EnvelopeState
from finalynx.portfolio.folder import Portfolio
from finalynx.simulator.actions import AutoBalance
from finalynx.simulator.events import Event
Expand All @@ -18,11 +22,24 @@
class Simulation:
"""Configuration class to launch a Finalynx simulation."""

events: List[Event]
# List of events to execute
events: Optional[List[Event]] = None

# Inflation rate to apply to the portfolio every year (used if default events are enabled)
inflation: float = 2.0

# Simulation end date, if None the simulation will run for 100 years
end_date: Optional[date] = None

# Whether to pre-add common default events (yearly performance, auto-balance, etc.)
default_events: bool = True

# Whether to print the final portfolio state in the console after the simulation
print_final: bool = False

# Display the portfolio's worth in the console every `step` years
step_years: int = 5


class Timeline:
"""Main simulation engine to execute programmed actions on your portfolio."""
Expand All @@ -39,7 +56,7 @@ def __init__(
self.simulation = simulation
self._portfolio = portfolio
self._buckets = buckets
self._events = simulation.events
self._events = simulation.events if simulation.events else []

# Create default events in addition to the user ones and sort events by date
if simulation.default_events:
Expand All @@ -53,6 +70,10 @@ def __init__(
self.current_date = date.today()
self.end_date = simulation.end_date if simulation.end_date else date.today() + timedelta(weeks=100 * 52)

# Log some metrics during the simulation to display them at the end
self._log_dates: List[date] = [] # Dates at which the portfolio metrics were logged
self._log_env_states: Dict[str, List[float]] = {c.value: [] for c in EnvelopeState}

def run(self) -> None:
"""Step all events until the simulation limit is reached."""
self.goto(self.end_date)
Expand Down Expand Up @@ -101,6 +122,10 @@ def step(self) -> bool:
self._events += new_events
self._sort_events()

# Record the metrics if the year changed
if next_event.planned_date.year != self.current_date.year:
self._record_metrics()

# Move the current date to this event's date
self.current_date = next_event.planned_date
return False
Expand All @@ -119,6 +144,49 @@ def is_finished(self) -> bool:
or the limit date is reached."""
return len(self._events) == 0 or self.current_date >= self.end_date

def _record_metrics(self) -> None:
"""Record the portfolio's metrics at the current date to display later."""
self._log_dates.append(self.current_date)

# Record the envelope states and their amounts at this date
for key, value in AnalyzeInvestmentStates(self._portfolio).analyze(self.current_date).items():
self._log_env_states[key].append(value)

def chart(self) -> Dict[str, Any]:
"""Plot a Highcharts chart of the portfolio's envelopes' states and amounts over time."""
assert self._log_env_states, "Run the simulation before charting."

colors = {
"Unknown": "#434348",
"Closed": "#999999",
"Locked": "#F94144",
"Taxed": "#F9C74F",
"Free": "#7BB151",
}

return {
"chart": {"plotBackgroundColor": None, "plotBorderWidth": None, "plotShadow": False, "type": "area"},
"title": {"text": "Simulation", "align": "center"},
"plotOptions": {
"series": {"pointStart": self._log_dates[0].year},
"area": {
"stacking": "normal",
"lineColor": "#666666",
"lineWidth": 1,
"marker": {"lineWidth": 1, "lineColor": "#666666", "enabled": False},
},
},
"series": [
{
"name": key,
"data": value,
"color": colors[key],
}
for key, value in self._log_env_states.items()
],
"credits": {"enabled": False},
}

def _sort_events(self) -> None:
"""Internal method to sort the event list by planned date."""
self._events.sort(key=lambda event: event.planned_date)
Expand Down
3 changes: 3 additions & 0 deletions finalynx/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,7 @@ def main_filter(message: str) -> str:
-s --sources=string Comma-separated list of sources to activate, defaults to "finary" only
--sim-steps=int Display the simulated portfolio's worth every X years, defaults to 5
--future Print the portfolio after the simulation has finished
"""

0 comments on commit 78c1782

Please sign in to comment.