from typing import Any
from typing import Dict
from typing import TYPE_CHECKING
import numpy as np
from ..config import get_active_theme as TH
from .hierarchy import Hierarchy
if TYPE_CHECKING:
from .node import Node
[docs]class Target(Hierarchy):
"""Abstract class that defines an objective for a `Node` in the Portfolio tree."""
RESULT_NOK = {"name": "NOK", "symbol": "ร"}
RESULT_OK = {"name": "OK", "symbol": "โ"}
RESULT_TOLERATED = {"name": "Tolerated", "symbol": "โ"}
RESULT_INVEST = {"name": "Invest", "symbol": "โ"}
RESULT_DEVEST = {"name": "Devest", "symbol": "โ"}
RESULT_START = {"name": "Start", "symbol": "โฏ"}
RESULT_NONE = {"name": "No target", "symbol": "โฃ"}
def __init__(self) -> None:
"""Abstract Target class that holds the Node parent using this instance and provides
a common logic for rendering the amounts."""
super().__init__(parent=None)
self.parent: Node = self.parent # Tell mypy this class only has Nodes as parents.
[docs] def get_amount(self) -> float:
""":returns: The amount stored in the target's parent."""
if self.parent is None:
raise ValueError("[red]Target has no parent, not allowed.[/]")
return self.parent.get_amount()
[docs] def get_ideal(self) -> float:
""":returns: The ideal amount to be invested based on surrounding targets."""
return 0.0
[docs] def check(self) -> Dict[str, str]:
"""Default behavior to check if the parent's amount respects the target objective.
This method should be overriden by all subclasses to define custom-tailored logic.
:returns: A `Target.RESULT_*` object depending on the recommendation to be rendered
in the output console.
"""
if self.get_amount() == 0:
return Target.RESULT_START
return Target.RESULT_NONE
[docs] def prehint(self) -> str:
"""Virtual method for information to be printed between the amoutn and the name."""
ratio = round(self.get_ratio())
return f"{ratio:>2}%" if (self.parent.parent and 0 <= ratio <= 100) else ""
[docs] def hint(self) -> str:
"""Virtual method for information to be printed at the end of the parent's description."""
return "- Invest!" if self.check() == Target.RESULT_START else ""
[docs] def get_ratio(self, absolute: bool = False) -> float:
"""Returns how much this amount represents agains the reference in percentage (0-100%).
:param absolute: If false, gives the relative percentage of this line to the parent folder.
If true, gives the ratio between the current and ideal amount.
"""
if absolute:
total = self._get_parent_amount()
else:
total = self.parent.parent.get_amount() if self.parent.parent else 0
return 100 * self.get_amount() / total if total > 0 else 0
[docs] def _get_parent_amount(self) -> float:
""":returns: The value to be checked against (parent's amount)."""
if not self.parent or not self.parent.parent:
return 0
# If the parent also has a ratio target, propagate the reference amount
if isinstance(self.parent.parent.target, TargetRatio):
return self.parent.parent.target.get_ideal()
# Otherwise, simply get the parent's value
return self.parent.parent.get_amount()
# TODO deprecated?
# def render_amount(self, hide_amount: bool = False, n_characters: int = 0) -> str:
# """Check for the parent's amount against the target logic and format the amount based on the target recommendation.
# :param hide_amount: Replace the amounts by simple dots (easier to share the result), defaults to False.
# :param n_characters: Used by `Node` objects to align the amount with other nodes' renders.
# :returns: A string with a righ-formatted render of the parent's amount based on the target recommendation.
# """
# result = self.check()
# result = result if result != True else Target.RESULT_START # type: ignore # noqa: E712 TODO weird bug??? Workaround for now
# number = f"{round(self.get_amount()):>{n_characters}}" if not hide_amount else "ยทยทยท"
# return (
# f'[{result["color"]}]{result["symbol"]} {number} {self._render_currency()}[/][dim white]{self.prehint()}[/]'
# )
[docs] def render_ideal(self) -> str:
"""Ideal amount to be reached based on the current target and node
position in the tree. Must be overridden by subclasses."""
return ""
[docs] def render_goal(self) -> str:
"""Ideal amount or ratio to be reached based on the current target and node
position in the tree. Must be overridden by subclasses."""
return ""
[docs] def _render_target_name(self) -> str:
""":returns: The name of the target recommentation."""
return self.check()["name"]
[docs] def _render_target_symbol(self) -> str:
""":returns: The UFT-8 symbol associated to the target recommentation."""
return self.check()["symbol"]
[docs] def _render_target_color(self) -> str:
""":returns: The color associated to the target recommentation."""
return {
Target.RESULT_NOK["name"]: TH().TARGET_NOK,
Target.RESULT_OK["name"]: TH().TARGET_OK,
Target.RESULT_TOLERATED["name"]: TH().TARGET_TOLERATED,
Target.RESULT_INVEST["name"]: TH().TARGET_INVEST,
Target.RESULT_DEVEST["name"]: TH().TARGET_DEVEST,
Target.RESULT_START["name"]: TH().TARGET_START,
Target.RESULT_NONE["name"]: TH().TARGET_NONE,
}[self.check()["name"]]
[docs] def _render_currency(self) -> str:
""":returns: This parent's currency symbol, used for target render methods."""
if not self.parent:
raise ValueError("Target's parent must not be None.")
return self.parent._render_currency()
[docs] def to_dict(self) -> Dict[str, Any]:
"""Empty dict, should be overridden by subclasses."""
return {}
[docs] @staticmethod
def from_dict(dict: Dict[str, Any]) -> "Target":
if "type" not in dict:
return Target()
elif dict["type"] == "range":
return TargetRange(dict["target_min"], dict["target_max"], dict["tolerance"])
elif dict["type"] == "max":
return TargetMax(dict["target_max"], dict["tolerance"])
elif dict["type"] == "min":
return TargetMin(dict["target_min"], dict["tolerance"])
elif dict["type"] == "ratio":
return TargetRatio(dict["target_ratio"], dict["zone"], dict["tolerance"])
elif dict["type"] == "global_ratio":
return TargetGlobalRatio(dict["target_ratio"], dict["zone"], dict["tolerance"])
else:
raise ValueError("Unrecognized target type.")
[docs]class TargetRange(Target):
"""Target to make sure your node stays within a specified range."""
def __init__(self, target_min: float, target_max: float, tolerance: float = 0):
"""This target checks if the amount is between two values (with an optional tolerance).
:param target_min: Minimum threshold to get a `RESULT_OK`.
:param target_max: Maximum threshold to get a `RESULT_OK`.
:param tolerance: If the amount is between `target_min - tolerance` and `target_max + tolerance`,
the check will return a `RESULT_TOLERATED`.
"""
super().__init__()
self.target_min = target_min
self.target_max = target_max
self.tolerance = tolerance
[docs] def check(self) -> Dict[str, str]:
"""This function checks the conditions described in the init method."""
super_result = super().check()
if super_result != Target.RESULT_NONE:
return super_result
elif self._get_variable() < self.target_min - self.tolerance:
return Target.RESULT_INVEST
elif self._get_variable() < self.target_min:
return Target.RESULT_TOLERATED
elif self._get_variable() <= self.target_max:
return Target.RESULT_OK
elif self._get_variable() <= self.target_max + self.tolerance:
return Target.RESULT_TOLERATED
return Target.RESULT_DEVEST
[docs] def get_ideal(self) -> float:
""":returns: The ideal amount to be invested based on surrounding targets."""
if self.target_min <= self.get_amount() <= self.target_max:
return self.get_amount()
elif self.get_amount() < self.target_min:
return self.target_min
return self.target_max
[docs] def render_ideal(self) -> str:
""":returns: The average between target boundaries."""
return f"{round(self.get_ideal())} {self._render_currency()} "
[docs] def render_goal(self) -> str:
""":returns: Same as ideal amount."""
return f"{round(self.get_ideal())} {self._render_currency()} "
[docs] def _get_variable(self) -> float:
"""Internal method that gives the value to be checked (overriden by subclasses)."""
return self.get_amount()
[docs] def hint(self) -> str:
""":returns: A formatted description of the target (at the end of the line)."""
return f"- Range {self.target_min}-{self.target_max} {self._render_currency()}"
[docs] def to_dict(self) -> Dict[str, Any]:
return {
"type": "range",
"target_min": self.target_min,
"target_max": self.target_max,
"tolerance": self.tolerance,
}
[docs]class TargetMax(TargetRange):
"""Target to make sure your node does not exceed a specified value."""
def __init__(self, target_max: float, tolerance: float = 0):
"""This target checks if the amount is below a specified threshold (with an optional tolerance).
:param target_max: Maximum threshold to get a `RESULT_OK`.
:param tolerance: If the amount is at most `target_max + tolerance`, the check will return a `RESULT_TOLERATED`.
"""
super().__init__(0, target_max, tolerance)
[docs] def get_ideal(self) -> float:
""":returns: The ideal amount to be invested based on surrounding targets."""
return self.target_max
[docs] def hint(self) -> str:
""":returns: A formatted description of the target."""
return f"- Maximum {self.target_max} {self._render_currency()}"
[docs] def to_dict(self) -> Dict[str, Any]:
return {
"type": "max",
"target_max": self.target_max,
"tolerance": self.tolerance,
}
[docs]class TargetMin(TargetRange):
"""Target to make sure your node does not go under a specified value."""
def __init__(self, target_min: float, tolerance: float = 0):
"""This target checks if the amount is above a specified threshold (with an optional tolerance).
:param target_min: Minimum threshold to get a `RESULT_OK`.
:param tolerance: If the amount is at least `target_min - tolerance`, the check will return a `RESULT_TOLERATED`.
"""
super().__init__(target_min, np.inf, tolerance)
[docs] def get_ideal(self) -> float:
""":returns: The ideal amount to be invested based on surrounding targets."""
return self.target_min
[docs] def hint(self) -> str:
""":returns: A formatted description of the target."""
return f"- Minimum {self.target_min} {self._render_currency()}"
[docs] def to_dict(self) -> Dict[str, Any]:
return {
"type": "min",
"target_min": self.target_min,
"tolerance": self.tolerance,
}
[docs]class TargetRatio(TargetRange):
"""Target to make sure your node represents a specified ratio in a folder."""
def __init__(self, target_ratio: float, zone: float = 4, tolerance: float = 2):
"""This target checks if the amount represents a specified ratio of the total amounf of the parent node
(with an optional tolerance).
:param target_ratio: Target to get a `RESULT_OK`.
:param zone: Accepted tolerance to still return a `RESULT_OK`.
:param tolerance: If the amount is between `target_ratio - (zone + tolerance) / 2` and `target_ratio + (zone + tolerance) / 2`,
the check will return a `RESULT_TOLERATED`.
"""
target_min = max(target_ratio - zone, 0)
target_max = min(target_ratio + zone, 100)
super().__init__(target_min, target_max, tolerance)
self.target_ratio = target_ratio
self.zone = zone
[docs] def get_ideal(self) -> float:
""":returns: How much this amount represents agains the reference in percentage (0-100%)."""
return self._get_parent_amount() * self.target_ratio / 100
[docs] def render_goal(self) -> str:
""":returns: The target ratio as a string."""
return f"{self.target_ratio:>2} % "
[docs] def _get_variable(self) -> float:
""":returns: The value to be checked."""
return self.get_ratio(absolute=True)
# TODO how to add an option to show the ideal amount next to the current amount?
# def hint(self) -> str:
# """:returns: A rich-formatted view of the calculated percentage."""
# return f"โ {self.get_ideal()} {self._render_currency()}"
[docs] def hint(self) -> str:
""":returns: A formatted description of the target."""
return f"โ {self.target_ratio}%"
[docs] def to_dict(self) -> Dict[str, Any]:
return {
"type": "ratio",
"target_ratio": self.target_ratio,
"zone": self.zone,
"tolerance": self.tolerance,
}
[docs]class TargetGlobalRatio(TargetRatio):
"""Target to make sure your node represents a specified ratio in your portfolio."""
def __init__(self, target_ratio: float, zone: float = 4, tolerance: float = 0):
"""This target checks if the amount represents a specified ratio of the total amount of your entire portfolio.
:param target_ratio: Target to get a `RESULT_OK`.
:param zone: Accepted tolerance to still return a `RESULT_OK`.
:param tolerance: If the amount is between `target_ratio - (zone + tolerance) / 2` and `target_ratio + (zone + tolerance) / 2`,
the check will return a `RESULT_TOLERATED`.
"""
super().__init__(target_ratio, zone, tolerance)
[docs] def _get_parent_amount(self) -> float:
""":returns: The value to be checked against (portfolio amount)."""
root = self.parent
while root.parent is not None:
root = root.parent
return root.get_amount()
[docs] def hint(self) -> str:
""":returns: A formatted description of the target."""
return f"โ {self.target_ratio}% (global)"
[docs] def to_dict(self) -> Dict[str, Any]:
return {
"type": "global_ratio",
"target_ratio": self.target_ratio,
"zone": self.zone,
"tolerance": self.tolerance,
}