Skip to content

Commit

Permalink
refactor: create IndexPage class
Browse files Browse the repository at this point in the history
Cleans up the create_app function. Also cleaned up the IndexPage functionalty in general, extracting functions into `simulator.py` and `config.py` and created tests
  • Loading branch information
chriskelly committed Nov 2, 2023
1 parent 7bea33c commit b5310e5
Show file tree
Hide file tree
Showing 7 changed files with 180 additions and 57 deletions.
29 changes: 5 additions & 24 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,18 @@
"""Flask app definition"""

from flask import Flask, redirect, render_template, request, url_for
import pandas as pd
from app.models.simulator import SimulationEngine
from flask import Flask, request
from app.routes.api import api as api_blueprint
from app.routes.index import IndexPage


def create_app():
"""Create the Flask app with index route"""
app = Flask(__name__)
app.register_blueprint(api_blueprint, url_prefix="/api")

@app.route("/", methods=["GET", "POST"])
def index():
df = pd.DataFrame()
success_percentage = ""
if request.method == "POST":
edited_config = request.form["edited_config"]
with open("config.yml", "w") as config_file:
config_file.write(edited_config)
if "run_simulation" in request.form:
engine = SimulationEngine()
engine.gen_all_trials()
df = engine.results.as_dataframes()[0]
success_percentage = round(
100 * engine.results.success_rate(), ndigits=1
)
with open("config.yml", "r") as config_file:
config = config_file.read()
return render_template(
"index.html",
config=config,
table=df.to_html(classes="table table-striped"),
success_percentage=success_percentage,
)
index_page = IndexPage(request)
return index_page.template

return app
24 changes: 23 additions & 1 deletion app/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,11 +568,33 @@ def get_config(config_path: Path) -> User:
try:
config = User(**yaml_content)
except ValidationError as error:
print(error)
raise error

# config.net_worth_target is considered global
# and overwrites any net_worth_target value left unspecified
if config.net_worth_target:
attribute_filler(config, "net_worth_target", config.net_worth_target)

return config


def read_config_file(config_path: Path = constants.CONFIG_PATH) -> str:
"""Reads the config file and returns the text"""
with open(config_path, "r", encoding="utf-8") as config_file:
config_text = config_file.read()
return config_text


def write_config_file(config_text: str, config_path: Path = constants.CONFIG_PATH):
"""Writes the config file after validation"""
try:
data_as_yaml = yaml.safe_load(config_text)
User(**data_as_yaml)
except (yaml.YAMLError, TypeError) as error:
print(f"Invalid YAML format: {error}")
raise error
except ValidationError as error:
print(f"Invalid config: {error}")
raise error
with open(config_path, "w", encoding="utf-8") as config_file:
config_file.write(config_text)
80 changes: 49 additions & 31 deletions app/models/simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@
Classes:
SimulationTrial: A single simulation trial representing one modeled lifetime
Results:
ResultsLabels: Labels for the columns in the results DataFrame
SimulationEngine:
Results: Results of a series of simulation trials
SimulationEngine: Simulation Controller
Functions:
gen_simulation_results(): Generates a Results object
"""
from dataclasses import dataclass
from enum import Enum
Expand All @@ -30,30 +35,6 @@
from app.models.financial.interval import gen_first_interval


class ResultLabels(Enum):
"""Labels for the columns in the results DataFrame"""

DATE = "Date"
NET_WORTH = "Net Worth"
INFLATION = "Inflation"
JOB_INCOME = "Job Income"
SS_USER = "SS User"
SS_PARTNER = "SS Partner"
PENSION = "Pension"
TOTAL_INCOME = "Total Income"
SPENDING = "Spending"
KIDS = "Kids"
INCOME_TAXES = "Income Taxes"
MEDICARE_TAXES = "Medicare Taxes"
SOCIAL_SECURITY_TAXES = "Social Security Taxes"
PORTFOLIO_TAXES = "Portfolio Taxes"
TOTAL_TAXES = "Total Taxes"
TOTAL_COSTS = "Total Costs"
PORTFOLIO_RETURN = "Portfolio Return"
ANNUITY = "Annuity"
NET_TRANSACTION = "Net Transaction"


class SimulationTrial:
"""A single simulation trial representing one modeled lifetime
Expand Down Expand Up @@ -100,6 +81,34 @@ def __init__(
self.intervals[-1].gen_next_interval(self._controllers)
)

def get_success(self) -> bool:
"""Returns True if the trial ended with a positive net worth"""
return self.intervals[-1].state.net_worth > 0


class ResultLabels(Enum):
"""Labels for the columns in the results DataFrame"""

DATE = "Date"
NET_WORTH = "Net Worth"
INFLATION = "Inflation"
JOB_INCOME = "Job Income"
SS_USER = "SS User"
SS_PARTNER = "SS Partner"
PENSION = "Pension"
TOTAL_INCOME = "Total Income"
SPENDING = "Spending"
KIDS = "Kids"
INCOME_TAXES = "Income Taxes"
MEDICARE_TAXES = "Medicare Taxes"
SOCIAL_SECURITY_TAXES = "Social Security Taxes"
PORTFOLIO_TAXES = "Portfolio Taxes"
TOTAL_TAXES = "Total Taxes"
TOTAL_COSTS = "Total Costs"
PORTFOLIO_RETURN = "Portfolio Return"
ANNUITY = "Annuity"
NET_TRANSACTION = "Net Transaction"


@dataclass
class Results:
Expand Down Expand Up @@ -147,11 +156,13 @@ def as_dataframes(self) -> list[pd.DataFrame]:
dataframes.append(df)
return dataframes

def success_rate(self) -> float:
"""Returns the percentage of trials that ended with a positive net worth"""
return sum(
trial.intervals[-1].state.net_worth > 0 for trial in self.trials
) / len(self.trials)
def calc_success_rate(self) -> float:
"""Returns the rate of trials that ended with a positive net worth"""
return sum(trial.get_success() for trial in self.trials) / len(self.trials)

def calc_success_percentage(self) -> str:
"""Returns the formatted percentage of trials that ended with a positive net worth"""
return str(round(100 * self.calc_success_rate(), ndigits=1))


class SimulationEngine:
Expand Down Expand Up @@ -201,3 +212,10 @@ def gen_all_trials(self):
)
for i in range(self._trial_qty)
]


def gen_simulation_results() -> Results:
"""Runs a simulation and returns the results"""
engine = SimulationEngine()
engine.gen_all_trials()
return engine.results
45 changes: 45 additions & 0 deletions app/routes/index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""
This module contains the IndexPage class, which represents the index page of the LifeFinances app.
It also contains functions for reading and writing configuration files, and generating simulation results.
"""

from flask import Request, render_template
from app.models.config import read_config_file, write_config_file
from app.models.simulator import gen_simulation_results


class IndexPage:
"""
A class representing the index page of the LifeFinances app.
Attributes:
template (str): The HTML template for the index page.
"""

def __init__(self, req: Request):
self._first_results_table = ""
self._success_percentage = ""
if req.method == "POST":
self._handle_form(req.form)
self._config = read_config_file()

@property
def template(self):
"""Render the index page template"""
return render_template(
"index.html",
config=self._config,
first_results_table=self._first_results_table,
success_percentage=self._success_percentage,
)

def _handle_form(self, form: dict[str, str]):
write_config_file(form["edited_config"])
if "run_simulation" in form:
self._update_simulation_results()

def _update_simulation_results(self):
results = gen_simulation_results()
first_results = results.as_dataframes()[0]
self._first_results_table = first_results.to_html(classes="table table-striped")
self._success_percentage = results.calc_success_percentage()
2 changes: 1 addition & 1 deletion app/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ <h1>Chance of Success: {{success_percentage}}%</h1>
<h1>First Result</h1>
<div class="table-container">
<table class="table table-striped">
{{ table | safe }}
{{ first_results_table | safe }}
</table>
</div>
</div>
Expand Down
36 changes: 36 additions & 0 deletions tests/models/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from typing import Optional
from dataclasses import dataclass
from pytest_mock import MockerFixture
import yaml
import pytest
from pydantic import ValidationError
Expand All @@ -16,6 +17,7 @@
StrategyOptions,
attribute_filler,
_income_profiles_in_order,
write_config_file,
)


Expand Down Expand Up @@ -185,3 +187,37 @@ def test_either_income_or_net_worth():
}
with pytest.raises(ValidationError, match="1 validation error"):
User(**data)


def test_write_config_file(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
"""Ensure write_config_file works as expected and fails when necessary"""
with open(
constants.SAMPLE_MIN_CONFIG_NET_WORTH_PATH, "r", encoding="utf-8"
) as file:
min_config = file.read()

mock_open = mocker.MagicMock()
monkeypatch.setattr("builtins.open", mock_open)

# Test valid YAML
write_config_file(min_config)
mock_open.assert_called_once()

# Test invalid YAML loading
config_text = min_config.replace(":", "")
with pytest.raises(TypeError):
write_config_file(config_text)

# Test invalid YAML format
invalid_yaml = """
key: value
- item1
- item2
"""
with pytest.raises(yaml.YAMLError):
write_config_file(invalid_yaml)

# Test invalid config
config_text = min_config.replace("age", "wrong_key")
with pytest.raises(ValidationError):
write_config_file(config_text)
21 changes: 21 additions & 0 deletions tests/models/test_simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

from pathlib import Path
import pytest
from pytest_mock import MockerFixture
from app.data import constants
from app.models.simulator import (
Results,
SimulationEngine,
ResultLabels,
SimulationTrial,
)


Expand Down Expand Up @@ -63,3 +66,21 @@ def test_costs(self):
assert (self.results[ResultLabels.INCOME_TAXES.value] <= 0).all()
assert (self.results[ResultLabels.MEDICARE_TAXES.value] <= 0).all()
assert (self.results[ResultLabels.SOCIAL_SECURITY_TAXES.value] <= 0).all()

def test_calc_success_rate(self, mocker: MockerFixture):
"""calc_success_rate should return the correct value"""
successful_trial_mock = mocker.MagicMock(spec=SimulationTrial)
successful_trial_mock.get_success.return_value = True
failed_trial_mock = mocker.MagicMock(spec=SimulationTrial)
failed_trial_mock.get_success.return_value = False
results = Results(
trials=[
successful_trial_mock,
successful_trial_mock,
successful_trial_mock,
failed_trial_mock,
failed_trial_mock,
]
)
assert results.calc_success_rate() == pytest.approx(0.6)
assert results.calc_success_percentage() == "60.0"

0 comments on commit b5310e5

Please sign in to comment.