Skip to content

Commit

Permalink
feat: add autobalance default quarterly event
Browse files Browse the repository at this point in the history
  • Loading branch information
MadeInPierre committed Jul 31, 2023
1 parent 89cb3b3 commit a488768
Show file tree
Hide file tree
Showing 7 changed files with 115 additions and 23 deletions.
9 changes: 6 additions & 3 deletions finalynx/assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
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 @@ -230,7 +231,7 @@ def run(self) -> None:
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):>3}[/] k€")
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):
Expand All @@ -242,13 +243,15 @@ def append_worth(year: int, amount: float) -> None:

console.log(f" Portfolio will be worth [{TH().ACCENT}]{self.portfolio.get_amount():.0f} €[/]")

# renders.append(self.render_mainframe())

# 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
26 changes: 26 additions & 0 deletions finalynx/portfolio/bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,32 @@ def get_used_amount(self) -> float:
""":return: The total amount used from the bucket until now."""
return self.amount_used

def add_amount(self, amount: float) -> None:
"""Add or remove an amount to the bucket's lines. This can be used to dynamically change the
bucket's total amount, e.g. to apply recommendations from Finalynx during the simulation"""

# If the amount is positive, add the amount to the last line in the bucket
if amount > 0:
if not self.lines:
raise ValueError("Cannot add amount to an empty bucket.")
self.lines[-1].amount += amount

# If the amount is negative, remove successively from each line
else:
amount *= -1
removed_amount = 0.0
for line in reversed(self.lines):
remaining_amount = amount - removed_amount

if line.amount >= remaining_amount:
line.amount -= remaining_amount
return
else:
removed_amount += line.amount
line.amount = 0

raise ValueError("Attempted to remove too much from the bucket.")

def reset(self) -> None:
"""Go back to a state where no amount was used."""
self._prev_amount_used = 0
Expand Down
1 change: 1 addition & 0 deletions finalynx/portfolio/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def __init__(
render_aliases: Dict[str, str] = {
"[text]": "[target_text][prehint] [name] [hint][newline]",
"[console]": "[target][prehint] [account_code][name_color][name][/] [hint][newline]",
"[console_simple]": "[target] [name_color][name][/] [hint][newline]",
"[console_ideal]": "[bold][ideal][/][account_code][name_color][name][/][newline]",
"[console_delta]": "[delta][account_code][name_color][name][/][newline]",
"[console_perf]": "[bold][perf][/][account_code][name_color][name][/][newline]",
Expand Down
4 changes: 2 additions & 2 deletions finalynx/portfolio/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,9 @@ def __init__(self, target_ratio: float, zone: float = 4, tolerance: float = 2):
self.target_ratio = target_ratio
self.zone = zone

def get_ideal(self) -> int:
def get_ideal(self) -> float:
""":returns: How much this amount represents agains the reference in percentage (0-100%)."""
return round(self._get_parent_amount() * self.target_ratio / 100)
return self._get_parent_amount() * self.target_ratio / 100

def render_goal(self) -> str:
""":returns: The target ratio as a string."""
Expand Down
54 changes: 54 additions & 0 deletions finalynx/simulator/actions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from typing import Any
from typing import List
from typing import Optional
from typing import TYPE_CHECKING
Expand All @@ -8,6 +9,7 @@
from finalynx.portfolio.folder import SharedFolder
from finalynx.portfolio.line import Line
from finalynx.portfolio.node import Node
from finalynx.portfolio.targets import TargetRatio

if TYPE_CHECKING:
from finalynx.simulator.events import Event
Expand Down Expand Up @@ -52,6 +54,11 @@ def apply(self, portfolio: Portfolio) -> List["Event"]:

class ApplyPerformance(Action):
def __init__(self, inflation: float = 2.0, period_years: float = 1.0) -> None:
"""This action applies every line's expected performance defined in `LinePerf`
instances for the entire portfolio objecti.
:param inflation: Float to reduce each line's performance by this number.
:param period_years: Duration to apply the performance on. E.g. for one month, use 1/12.
"""
self.period_years = period_years
self.inflation = inflation
super().__init__()
Expand All @@ -68,6 +75,7 @@ def apply(self, portfolio: Portfolio) -> List["Event"]:

# Collect the buckets in the tree, apply the performance for each of
# their lines, and process again to redistribute the new amounts.
# Timeline already processes the tree avec each event, no need here.
for bucket in set(self._buckets):
for line in bucket.lines:
line.apply_perf(self.inflation, self.period_years)
Expand All @@ -85,3 +93,49 @@ def _apply_perf(self, node: Node) -> None:
node.apply_perf(self.inflation, self.period_years)
else:
raise ValueError("Unexpected node type.")


class AutoBalance(Action):
def apply(self, portfolio: Portfolio) -> List["Event"]:
"""This action automatically applies the ideal amounts auto-calculated
in the portfolio tree. This only applies to `Line` and `SharedFolder`
instances that have a `TargetRatio` target. The amounts are balanced
depending on the target percentages for each node.
Lines auto-added by envelope in folders are also balanced with equal
percentages set for each child in the same folder.
"""
ideals = self._get_ideals(portfolio)
self._set_ideals(portfolio, ideals)
return []

def _get_ideals(self, node: Node) -> List[Any]:
"""Save the ideal amounts calculated in the tree before applying them to
avoid inconsistent states."""
if isinstance(node, Folder) and not isinstance(node, SharedFolder):
return [self._get_ideals(c) for c in node.children]
else:
if (
node.target.__class__.__name__ == "Target"
and node.parent
and isinstance(node.parent.target, TargetRatio)
):
return [node.parent.get_ideal() / len(node.parent.children)]
return [node.get_ideal()]

def _set_ideals(self, node: Node, ideals: List[Any]) -> None:
"""Set the ideal amounts for each `Line` and `SharedFolder`."""

# Traverse the tree to get to the leaves
if isinstance(node, Folder) and not isinstance(node, SharedFolder):
for i_child, child in enumerate(node.children):
self._set_ideals(child, ideals[i_child])

# At a leaf level, only update the amount if it's a node with a ratio target.
# Add an exception for Lines auto-added in folders (no target set but the parent folder has a ratio)
elif isinstance(node.target, TargetRatio) or (
node.target.__class__.__name__ == "Target" and node.parent and isinstance(node.parent.target, TargetRatio)
):
if isinstance(node, SharedFolder):
node.bucket.add_amount(ideals[0] - node.get_amount())
elif isinstance(node, Line):
node.amount = ideals[0]
10 changes: 7 additions & 3 deletions finalynx/simulator/recurrence.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,16 @@ class MonthlyRecurrence(Recurrence):
def __init__(
self,
day_of_the_month: int,
n_months: int = 1,
until: Optional[date] = None,
) -> None:
super().__init__(until)
self.day_of_the_month = day_of_the_month
self.n_months = n_months

def _next_date(self, current_date: date) -> date:
if current_date.month == 12:
return date(current_date.year + 1, 1, self.day_of_the_month)
return date(current_date.year, current_date.month + 1, self.day_of_the_month)
next_month = current_date.month + self.n_months

if next_month > 12:
return date(current_date.year + 1, next_month - 12, self.day_of_the_month)
return date(current_date.year, next_month, self.day_of_the_month)
34 changes: 19 additions & 15 deletions finalynx/simulator/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
from typing import List
from typing import Optional

from finalynx.config import get_active_theme as TH
from finalynx.console import console
from finalynx.portfolio.bucket import Bucket
from finalynx.portfolio.folder import Portfolio
from finalynx.simulator.actions import AutoBalance
from finalynx.simulator.events import Event
from finalynx.simulator.events import YearlyPerformance
from finalynx.simulator.recurrence import MonthlyRecurrence


@dataclass
Expand All @@ -31,13 +35,13 @@ def __init__(
self.simulation = simulation
self._portfolio = portfolio
self._buckets = buckets

# Create default events, add the user ones, and sort by date
self._events = simulation.events

# Create default events in addition to the user ones and sort events by date
if simulation.default_events:
self._events += [
YearlyPerformance(simulation.inflation),
# TODO auto-balance the portfolio by following the recommendations
Event(AutoBalance(), recurrence=MonthlyRecurrence(1, n_months=3)),
]
self._sort_events()

Expand All @@ -46,17 +50,19 @@ def __init__(
self.end_date = simulation.end_date if simulation.end_date else date.today() + timedelta(weeks=100 * 52)

def run(self) -> None:
"""Step all events until the simulation limit is reached."""
self.goto(self.end_date)

def goto(self, target_date: date) -> None:
""""""
if target_date == self.current_date:
return
elif target_date > self.current_date:
self.step_until(target_date)
else:
self.unstep_until(target_date)
self.current_date = target_date
"""Step until the target date is reached (in the future or past)."""
with console.status(f"[bold {TH().ACCENT}]Moving timeline until {target_date}...", spinner_style=TH().ACCENT):
if target_date == self.current_date:
return
elif target_date > self.current_date:
self.step_until(target_date)
else:
self.unstep_until(target_date)
self.current_date = target_date

def step_until(self, target_date: date) -> None:
"""Execute all events until the specified date is reached."""
Expand Down Expand Up @@ -93,10 +99,6 @@ def step(self) -> bool:

# Move the current date to this event's date
self.current_date = next_event.planned_date
# console.log(
# f"{next_event.planned_date} Portfolio has "
# f"{round(self._portfolio.get_amount())} € after event {next_event}"
# )
return False

def unstep_until(self, target_date: date) -> None:
Expand All @@ -109,6 +111,8 @@ def unstep(self) -> None:

@property
def is_finished(self) -> bool:
"""The timeline is finished if there are no events left to step
or the limit date is reached."""
return len(self._events) == 0 or self.current_date >= self.end_date

def _sort_events(self) -> None:
Expand Down

0 comments on commit a488768

Please sign in to comment.