folioflex 1.2.2__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {folioflex-1.2.2/folioflex.egg-info → folioflex-1.4.0}/PKG-INFO +45 -46
  2. {folioflex-1.2.2 → folioflex-1.4.0}/README.md +4 -6
  3. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/budget/budget.py +207 -78
  4. folioflex-1.4.0/folioflex/configs/config.yml +179 -0
  5. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/app.py +23 -1
  6. folioflex-1.4.0/folioflex/dashboard/assets/folioflex_192x192.png +0 -0
  7. folioflex-1.4.0/folioflex/dashboard/assets/manifest.json +12 -0
  8. folioflex-1.4.0/folioflex/dashboard/components/dash_formats.py +105 -0
  9. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/components/layouts.py +7 -6
  10. folioflex-1.4.0/folioflex/dashboard/pages/budget.py +582 -0
  11. folioflex-1.4.0/folioflex/dashboard/pages/macro.py +181 -0
  12. folioflex-1.4.0/folioflex/dashboard/pages/personal.py +446 -0
  13. folioflex-1.4.0/folioflex/dashboard/pages/sectors.py +238 -0
  14. folioflex-1.4.0/folioflex/dashboard/pages/stocks.py +262 -0
  15. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/utils/dashboard_helper.py +70 -14
  16. folioflex-1.4.0/folioflex/portfolio/assets.py +275 -0
  17. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/portfolio/broker.py +40 -22
  18. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/portfolio/heatmap.py +12 -1
  19. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/portfolio/helper.py +13 -5
  20. folioflex-1.4.0/folioflex/portfolio/loans.py +303 -0
  21. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/portfolio/portfolio.py +178 -53
  22. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/portfolio/wrappers.py +248 -53
  23. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/utils/cli.py +2 -2
  24. folioflex-1.4.0/folioflex/utils/config_helper.py +202 -0
  25. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/utils/cq.py +3 -0
  26. folioflex-1.4.0/folioflex/utils/database.py +254 -0
  27. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/utils/mailer.py +2 -2
  28. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/version.py +1 -1
  29. {folioflex-1.2.2 → folioflex-1.4.0/folioflex.egg-info}/PKG-INFO +45 -46
  30. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex.egg-info/SOURCES.txt +8 -6
  31. folioflex-1.4.0/folioflex.egg-info/requires.txt +47 -0
  32. {folioflex-1.2.2 → folioflex-1.4.0}/pyproject.toml +44 -40
  33. folioflex-1.4.0/tests/test_asset_loans.py +72 -0
  34. {folioflex-1.2.2 → folioflex-1.4.0}/tests/test_budget.py +25 -5
  35. {folioflex-1.2.2 → folioflex-1.4.0}/tests/test_portfolio.py +3 -2
  36. {folioflex-1.2.2 → folioflex-1.4.0}/tests/test_utils.py +2 -2
  37. folioflex-1.2.2/folioflex/configs/budget_personal.ini +0 -33
  38. folioflex-1.2.2/folioflex/configs/config.ini +0 -31
  39. folioflex-1.2.2/folioflex/configs/portfolio_dash.ini +0 -21
  40. folioflex-1.2.2/folioflex/configs/portfolio_demo.ini +0 -35
  41. folioflex-1.2.2/folioflex/configs/portfolio_personal.ini +0 -89
  42. folioflex-1.2.2/folioflex/configs/transactions_dash.csv +0 -13
  43. folioflex-1.2.2/folioflex/dashboard/pages/budget.py +0 -194
  44. folioflex-1.2.2/folioflex/dashboard/pages/macro.py +0 -157
  45. folioflex-1.2.2/folioflex/dashboard/pages/personal.py +0 -447
  46. folioflex-1.2.2/folioflex/dashboard/pages/sectors.py +0 -206
  47. folioflex-1.2.2/folioflex/dashboard/pages/stocks.py +0 -267
  48. folioflex-1.2.2/folioflex/utils/config_helper.py +0 -206
  49. folioflex-1.2.2/folioflex.egg-info/requires.txt +0 -46
  50. {folioflex-1.2.2 → folioflex-1.4.0}/LICENSE.md +0 -0
  51. {folioflex-1.2.2 → folioflex-1.4.0}/MANIFEST.in +0 -0
  52. {folioflex-1.2.2 → folioflex-1.4.0}/docs/source/conf.py +0 -0
  53. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/__init__.py +0 -0
  54. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/budget/__init__.py +0 -0
  55. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/budget/models.py +0 -0
  56. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/chatbot/__init__.py +0 -0
  57. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/chatbot/providers.py +0 -0
  58. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/chatbot/scraper.py +0 -0
  59. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/configs/__init__.py +0 -0
  60. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/configs/transactions_demo.csv +0 -0
  61. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/__init__.py +0 -0
  62. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/assets/folioflex_logo.ico +0 -0
  63. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/assets/folioflex_logo.png +0 -0
  64. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/assets/github-mark-white.png +0 -0
  65. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/components/__init__.py +0 -0
  66. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/components/auth.py +0 -0
  67. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/pages/__init__.py +0 -0
  68. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/pages/ideas.py +0 -0
  69. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/pages/login.py +0 -0
  70. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/pages/logout.py +0 -0
  71. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/dashboard/utils/__init__.py +0 -0
  72. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/market/__init__.py +0 -0
  73. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/market/screener.py +0 -0
  74. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/portfolio/__init__.py +0 -0
  75. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/utils/__init__.py +0 -0
  76. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex/utils/custom_logger.py +0 -0
  77. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex.egg-info/dependency_links.txt +0 -0
  78. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex.egg-info/entry_points.txt +0 -0
  79. {folioflex-1.2.2 → folioflex-1.4.0}/folioflex.egg-info/top_level.txt +0 -0
  80. {folioflex-1.2.2 → folioflex-1.4.0}/setup.cfg +0 -0
  81. {folioflex-1.2.2 → folioflex-1.4.0}/setup.py +0 -0
  82. {folioflex-1.2.2 → folioflex-1.4.0}/tests/test_chatbot.py +0 -0
  83. {folioflex-1.2.2 → folioflex-1.4.0}/tests/test_wrappers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: folioflex
3
- Version: 1.2.2
3
+ Version: 1.4.0
4
4
  Summary: A collection of portfolio tracking capabilities
5
5
  Author-email: John Koestner <johnkoestner@outlook.com>
6
6
  License: The MIT License (MIT)
@@ -27,54 +27,55 @@ License: The MIT License (MIT)
27
27
  Project-URL: homepage, https://github.com/jkoestner/folioflex
28
28
  Project-URL: documentation, https://jkoestner.github.io/folioflex/
29
29
  Project-URL: repository, https://github.com/jkoestner/folioflex
30
- Requires-Python: >=3.8
30
+ Requires-Python: >=3.10
31
31
  Description-Content-Type: text/markdown
32
32
  License-File: LICENSE.md
33
- Requires-Dist: ipywidgets>=8.1.0
34
- Requires-Dist: fredapi>=0.4.3
33
+ Requires-Dist: ipywidgets>=8.1.5
34
+ Requires-Dist: fredapi>=0.5.2
35
35
  Requires-Dist: jupyter-dash>=0.4.2
36
- Requires-Dist: jupyterlab>=4.0.10
36
+ Requires-Dist: jupyterlab>=4.3.0
37
37
  Requires-Dist: kaleido<0.2.0,>=0.1.0
38
- Requires-Dist: numpy>=1.22.3
39
- Requires-Dist: openpyxl>=3.0.7
40
- Requires-Dist: pandas>=0.25.0
41
- Requires-Dist: pandas-market-calendars>=4.1.4
42
- Requires-Dist: pyxirr>=0.7.2
43
- Requires-Dist: sqlalchemy>=2.0.23
44
- Requires-Dist: yfinance>=0.2.32
38
+ Requires-Dist: numpy>=1.26.4
39
+ Requires-Dist: openpyxl>=3.1.5
40
+ Requires-Dist: pandas>=2.2.3
41
+ Requires-Dist: pandas-market-calendars>=4.4.1
42
+ Requires-Dist: pyxirr>=0.10.6
43
+ Requires-Dist: sqlalchemy>=2.0.36
44
+ Requires-Dist: yfinance>=0.2.48
45
45
  Requires-Dist: tzlocal>=5.2
46
- Requires-Dist: dash>=1.0.2
46
+ Requires-Dist: dash>=2.18.2
47
+ Requires-Dist: dash-ag-grid>=31.2.0
47
48
  Requires-Dist: dash_bootstrap_components>=1.6.0
48
- Requires-Dist: dash-core-components>=1.0.0
49
- Requires-Dist: dash-html-components>=1.0.0
50
- Requires-Dist: dash-renderer>=1.0.0
51
- Requires-Dist: dash-table>=4.0.2
52
- Requires-Dist: Flask>=1.1.1
53
- Requires-Dist: Flask-Compress>=1.4.0
49
+ Requires-Dist: dash-core-components>=2.0.0
50
+ Requires-Dist: dash-html-components>=2.0.0
51
+ Requires-Dist: dash-renderer>=1.9.1
52
+ Requires-Dist: dash-table>=5.0.0
53
+ Requires-Dist: Flask>=3.0.3
54
+ Requires-Dist: Flask-Compress>=1.17
54
55
  Requires-Dist: Flask-Login>=0.6.3
55
- Requires-Dist: gunicorn>=19.9.0
56
- Requires-Dist: celery>=5.3.1
57
- Requires-Dist: flower>=2.0.0
58
- Requires-Dist: redis>=3.3.8
59
- Requires-Dist: g4f==0.2.4.1
60
- Requires-Dist: hugchat>=0.3.8
61
- Requires-Dist: openai>=1.3.7
62
- Requires-Dist: pyautogui>=0.9.53
63
- Requires-Dist: seleniumbase>=4.22.0
64
- Requires-Dist: emoji>=2.9.0
65
- Requires-Dist: gensim>=4.3.2
66
- Requires-Dist: scikit-learn>=1.3.2
67
- Requires-Dist: scipy==1.10.1
68
- Requires-Dist: psycopg2-binary
56
+ Requires-Dist: gunicorn>=23.0.0
57
+ Requires-Dist: celery>=5.4.0
58
+ Requires-Dist: flower>=2.0.1
59
+ Requires-Dist: redis>=5.2.0
60
+ Requires-Dist: g4f>=0.3.3.3
61
+ Requires-Dist: hugchat>=0.4.12
62
+ Requires-Dist: openai>=1.54.3
63
+ Requires-Dist: pyautogui>=0.9.54
64
+ Requires-Dist: seleniumbase>=4.32.8
65
+ Requires-Dist: emoji>=2.14.0
66
+ Requires-Dist: gensim>=4.3.3
67
+ Requires-Dist: scikit-learn>=1.5.2
68
+ Requires-Dist: scipy<1.14
69
+ Requires-Dist: psycopg2-binary>=2.9.10
69
70
  Provides-Extra: dev
70
- Requires-Dist: black>=23.7.0; extra == "dev"
71
+ Requires-Dist: black>=24.10.0; extra == "dev"
71
72
  Requires-Dist: isort>=5.13.2; extra == "dev"
72
- Requires-Dist: jupyterlab-code-formatter>=2.2.1; extra == "dev"
73
- Requires-Dist: pytest>=7.4.0; extra == "dev"
74
- Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
75
- Requires-Dist: ruff>=0.1.5; extra == "dev"
76
- Requires-Dist: sphinx<7.2,>=6.0; extra == "dev"
77
- Requires-Dist: sphinx_rtd_theme>=1.2.2; extra == "dev"
73
+ Requires-Dist: jupyterlab-code-formatter>=3.0.2; extra == "dev"
74
+ Requires-Dist: pytest>=8.3.3; extra == "dev"
75
+ Requires-Dist: pytest-cov>=6.0.0; extra == "dev"
76
+ Requires-Dist: ruff>=0.7.3; extra == "dev"
77
+ Requires-Dist: sphinx>=8.1; extra == "dev"
78
+ Requires-Dist: sphinx_rtd_theme>=3.0.1; extra == "dev"
78
79
 
79
80
  <div align="center">
80
81
  <img src="https://user-images.githubusercontent.com/50647092/260895211-7e9a8e6e-9f85-48ed-bb61-49ff2ff805b6.png"><br>
@@ -118,13 +119,11 @@ Simple investment portfolio tool that will track stock and provide returns and o
118
119
  **🔧 Features:**
119
120
 
120
121
  - **Market Screener**: Filter and find trending stocks. 🔍
121
- ![screener](docs/screenshots/screener.png)
122
- ![heatmap](docs/screenshots/heatmap.png)
122
+ ![stock](docs/screenshots/ffx_stock.gif)
123
123
  - **Portfolio Management**: Organize and track, your investments. 💼
124
- ![portfolio](docs/screenshots/portfolio.png)
124
+ ![portfolio](docs/screenshots/ffx_invest.gif)
125
125
  - **Budget Tool**: Create and monitor a budget. 💰
126
- ![budget](docs/screenshots/budget.png)
127
- ![budget_compare](docs/screenshots/budget_compare.png)
126
+ ![budget](docs/screenshots/ffx_budget.gif)
128
127
 
129
128
  **📚 Documentation:**
130
129
 
@@ -244,7 +243,7 @@ When using the portfolio class, the following code can be used to get the return
244
243
 
245
244
  ```python
246
245
  from folioflex.portfolio.portfolio import Portfolio
247
- config_path = "portfolio_demo.ini"
246
+ config_path = "portfolio_demo.yml"
248
247
  pf = Portfolio(
249
248
  config_path=config_path,
250
249
  portfolio='company_a'
@@ -40,13 +40,11 @@ Simple investment portfolio tool that will track stock and provide returns and o
40
40
  **🔧 Features:**
41
41
 
42
42
  - **Market Screener**: Filter and find trending stocks. 🔍
43
- ![screener](docs/screenshots/screener.png)
44
- ![heatmap](docs/screenshots/heatmap.png)
43
+ ![stock](docs/screenshots/ffx_stock.gif)
45
44
  - **Portfolio Management**: Organize and track, your investments. 💼
46
- ![portfolio](docs/screenshots/portfolio.png)
45
+ ![portfolio](docs/screenshots/ffx_invest.gif)
47
46
  - **Budget Tool**: Create and monitor a budget. 💰
48
- ![budget](docs/screenshots/budget.png)
49
- ![budget_compare](docs/screenshots/budget_compare.png)
47
+ ![budget](docs/screenshots/ffx_budget.gif)
50
48
 
51
49
  **📚 Documentation:**
52
50
 
@@ -166,7 +164,7 @@ When using the portfolio class, the following code can be used to get the return
166
164
 
167
165
  ```python
168
166
  from folioflex.portfolio.portfolio import Portfolio
169
- config_path = "portfolio_demo.ini"
167
+ config_path = "portfolio_demo.yml"
170
168
  pf = Portfolio(
171
169
  config_path=config_path,
172
170
  portfolio='company_a'
@@ -13,14 +13,14 @@ Some methods that are included are:
13
13
  """
14
14
 
15
15
  import re
16
+ from typing import Any, Dict, List, Optional, Union
16
17
 
17
18
  import emoji
18
19
  import numpy as np
19
20
  import pandas as pd
20
21
  import plotly.express as px
21
- import sqlalchemy as sa
22
22
 
23
- from folioflex.utils import config_helper, custom_logger
23
+ from folioflex.utils import config_helper, custom_logger, database
24
24
 
25
25
  logger = custom_logger.setup_logging(__name__)
26
26
 
@@ -40,16 +40,23 @@ class Budget:
40
40
 
41
41
  def __init__(
42
42
  self,
43
- config_path,
44
- budget,
45
- ):
43
+ config_path: str,
44
+ budget: str,
45
+ ) -> None:
46
46
  """Initialize the Portfolio class."""
47
47
  logger.info("Initializing Budget class.")
48
- self.config_dict = config_helper.get_config_options(config_path, budget)
48
+ self.config_path = config_path
49
+ self.config_dict = config_helper.get_config_options(
50
+ config_path, "budgets", budget
51
+ )
49
52
  self.budgets = self.config_dict["budgets"]
50
53
  self.default = self.config_dict["default"]
54
+ self.budget = budget
55
+ self.model = self.config_dict.get("model", None)
51
56
 
52
- def get_transactions(self, engine=None):
57
+ def get_transactions(
58
+ self, engine: Optional[Any] = None, user: Optional[str] = None
59
+ ) -> pd.DataFrame:
53
60
  """
54
61
  Get the transactions for the budget.
55
62
 
@@ -59,6 +66,8 @@ class Budget:
59
66
  ----------
60
67
  engine : SQLAlchemy engine
61
68
  The engine to connect to the database.
69
+ user : str, optional
70
+ The user to get the transactions for.
62
71
 
63
72
  Returns
64
73
  -------
@@ -79,16 +88,16 @@ class Budget:
79
88
 
80
89
  """
81
90
  if engine is None:
82
- engine = self._create_engine()
91
+ engine = database.Engine(self.config_path)
92
+ if user is None:
93
+ user = self.config_dict.get("user", None)
83
94
  logger.info("Getting transactions.")
84
95
  # creating a dataframe of transactions that include
85
96
  # the account name, item name, and transaction name
86
- with engine.connect() as conn, conn.begin():
87
- tx_df = pd.read_sql_table("transactions_table", conn)
88
- with engine.connect() as conn, conn.begin():
89
- item_df = pd.read_sql_table("items_table", conn)
90
- with engine.connect() as conn, conn.begin():
91
- account_df = pd.read_sql_table("accounts_table", conn)
97
+ user_df = engine.read_table("users_table")
98
+ item_df = engine.read_table("items_table")
99
+ account_df = engine.read_table("accounts_table")
100
+ tx_df = engine.read_table("transactions_table")
92
101
  # creating a grouped dataset
93
102
  tx_df = pd.merge(
94
103
  tx_df,
@@ -100,12 +109,22 @@ class Budget:
100
109
  )
101
110
  tx_df = pd.merge(
102
111
  tx_df,
103
- item_df[["id", "plaid_institution_id"]],
112
+ item_df[["id", "plaid_institution_id", "user_id"]],
104
113
  left_on="item_id",
105
114
  right_on="id",
106
115
  how="left",
107
116
  suffixes=[None, "_tmp"],
108
117
  )
118
+ tx_df = pd.merge(
119
+ tx_df,
120
+ user_df[["id", "username"]],
121
+ left_on="user_id",
122
+ right_on="id",
123
+ how="left",
124
+ suffixes=[None, "_tmp"],
125
+ )
126
+ if user is not None:
127
+ tx_df = tx_df[tx_df["username"] == user]
109
128
  tx_df = tx_df[
110
129
  [
111
130
  "id",
@@ -136,6 +155,12 @@ class Budget:
136
155
  """
137
156
  Modify the transactions for the budget.
138
157
 
158
+ The modifications include:
159
+ - Zero out the amount for the given columns
160
+ - Extract the text between quotes
161
+ - Preprocess the emoji in the text
162
+ - Remove the pending transactions.
163
+
139
164
  Parameters
140
165
  ----------
141
166
  tx_df : DataFrame
@@ -285,6 +310,74 @@ class Budget:
285
310
  return "Incoming Transfer"
286
311
  return "OTHER"
287
312
 
313
+ def identify_subscriptions(self, tx_df):
314
+ """
315
+ Identify possible subscriptions in the transactions.
316
+
317
+ The subscriptions are identified by the name and also the amount. If
318
+ the amount is similar for multiple transactions, and there are at least
319
+ a set amount of transcations, then it is likely a subscription.
320
+
321
+ Parameters
322
+ ----------
323
+ tx_df : DataFrame
324
+ The transactions to identify subscriptions for.
325
+
326
+ Returns
327
+ -------
328
+ subscriptions : DataFrame
329
+ The subscriptions identified in the transactions.
330
+
331
+ """
332
+ # default values
333
+ min_transactions = 3
334
+ interval_std_threshold = 5
335
+ amount_std_threshold = 0.1
336
+
337
+ # group data by name
338
+ grouped_df = tx_df.groupby("name")
339
+
340
+ # identify subscriptions
341
+ subscriptions = []
342
+ for name, group in grouped_df:
343
+ if len(group) < min_transactions:
344
+ continue
345
+
346
+ # calculating the intervals and ensure they are regular
347
+ intervals = group["date"].diff().dropna().dt.days
348
+ regular = intervals.std() <= interval_std_threshold
349
+
350
+ # calculate the amount and ensure it is consistent
351
+ amount_mean = group["amount"].mean()
352
+ if amount_mean == 0:
353
+ continue
354
+ relative_std = group["amount"].std() / amount_mean
355
+ consistent_amount = relative_std <= amount_std_threshold
356
+
357
+ # get the last date and amount
358
+ last_date = group["date"].max()
359
+ last_amount = group[group["date"] == last_date]["amount"].values[0]
360
+
361
+ if regular and consistent_amount:
362
+ subscriptions.append(
363
+ {
364
+ "Description": name,
365
+ "Occurrences": len(group),
366
+ "Mean Interval (Days)": intervals.mean(),
367
+ "Amount Mean": group["amount"].mean(),
368
+ "Amount Std Dev": group["amount"].std(),
369
+ "Last Date": last_date,
370
+ "Last Amount": last_amount,
371
+ }
372
+ )
373
+
374
+ subscriptions_df = pd.DataFrame(subscriptions)
375
+ subscriptions_df = subscriptions_df.sort_values(
376
+ by="Occurrences", ascending=False
377
+ )
378
+
379
+ return subscriptions_df
380
+
288
381
  def budget_view(self, tx_df, target_date, exclude_labels=None):
289
382
  """
290
383
  Provide a view of categories and their budget status.
@@ -308,6 +401,7 @@ class Budget:
308
401
  if exclude_labels is not None:
309
402
  tx_df = tx_df[~tx_df["label"].isin(exclude_labels)]
310
403
  budgets = {k: v for k, v in budgets.items() if k not in exclude_labels}
404
+
311
405
  # grouping transactions
312
406
  grouped_transactions = (
313
407
  tx_df[tx_df["date"].dt.to_period("M") == target_date]
@@ -316,6 +410,7 @@ class Budget:
316
410
  .reset_index()
317
411
  .sort_values("amount", ascending=True)
318
412
  )
413
+
319
414
  # adding in the budget
320
415
  unused_keys = set(budgets.keys()) - set(grouped_transactions["label"])
321
416
  unused_keys_df = pd.DataFrame({"label": list(unused_keys)})
@@ -327,6 +422,7 @@ class Budget:
327
422
  grouped_transactions["budget"] = grouped_transactions["budget"].fillna(
328
423
  float(self.config_dict["default"])
329
424
  )
425
+
330
426
  # providing a total
331
427
  totals = grouped_transactions.sum(numeric_only=True).to_frame().T
332
428
  totals["label"] = "TOTAL"
@@ -517,7 +613,81 @@ class Budget:
517
613
 
518
614
  return fig
519
615
 
520
- def category_tx_view(self, tx_df, target_date, category):
616
+ def display_category_trend(self, tx_df, category):
617
+ """
618
+ Display the category trend view as a bar chart.
619
+
620
+ Parameters
621
+ ----------
622
+ tx_df : DataFrame
623
+ The transactions to view income status for.
624
+ category : str
625
+ The category to view the trend for.
626
+
627
+ Returns
628
+ -------
629
+ fig : Figure
630
+ The category trend view as a line chart.
631
+
632
+ """
633
+ # filter data for category
634
+ category_df = tx_df[tx_df["label"] == category]
635
+ category_df = category_df.groupby(["month"])["amount"].sum().reset_index()
636
+ if len(category_df) == 0:
637
+ logger.warning(f"No transactions found for category: {category}")
638
+ return None
639
+
640
+ # budgets
641
+ category_budget = self.budgets.get(category, 0)
642
+ average_budget = category_df["amount"].mean()
643
+ twelve_month_avg = category_df["amount"].rolling(window=12).mean()
644
+
645
+ # create bar chart
646
+ fig = px.bar(
647
+ category_df,
648
+ x="month",
649
+ y="amount",
650
+ title=f"'{category}' Spending Trend",
651
+ labels={"month": "Month", "amount": "Amount"},
652
+ color_discrete_sequence=["blue"],
653
+ )
654
+
655
+ # add budget line
656
+ fig.add_scatter(
657
+ x=category_df["month"],
658
+ y=[category_budget] * len(category_df),
659
+ mode="lines",
660
+ line={"color": "red", "dash": "dash"},
661
+ name="Budget",
662
+ )
663
+
664
+ # add average line
665
+ fig.add_scatter(
666
+ x=category_df["month"],
667
+ y=[average_budget] * len(category_df),
668
+ mode="lines",
669
+ line={"color": "black", "dash": "dash"},
670
+ name="Average",
671
+ )
672
+
673
+ # add 12 month average line
674
+ fig.add_scatter(
675
+ x=category_df["month"],
676
+ y=twelve_month_avg,
677
+ mode="lines",
678
+ line={"color": "green", "dash": "dash"},
679
+ name="12-Month Average",
680
+ )
681
+
682
+ return fig
683
+
684
+ def category_tx_view(
685
+ self,
686
+ tx_df,
687
+ target_date,
688
+ category,
689
+ columns=None,
690
+ ):
521
691
  """
522
692
  Display the transactions view as a table.
523
693
 
@@ -529,6 +699,8 @@ class Budget:
529
699
  The date to view transactions for. Format: YYYY-MM.
530
700
  category : str
531
701
  The category to view transactions for.
702
+ columns : list (optional)
703
+ The columns to view in the table.
532
704
 
533
705
  Returns
534
706
  -------
@@ -536,10 +708,20 @@ class Budget:
536
708
  The category of transactions view as a table.
537
709
 
538
710
  """
711
+ if columns is None:
712
+ columns = [
713
+ "date",
714
+ "amount",
715
+ "name",
716
+ "label",
717
+ "official_name",
718
+ ]
719
+
539
720
  cat_tx_df = tx_df[
540
721
  (tx_df["date"].dt.to_period("M") == target_date)
541
722
  & (tx_df["label"].str.contains(category))
542
- ].sort_values(by="amount", ascending=False)
723
+ ].sort_values(by="date", ascending=False)
724
+ cat_tx_df = cat_tx_df[columns]
543
725
  return cat_tx_df
544
726
 
545
727
  def update_labels_db(self, tx_df, engine=None, label_column="label"):
@@ -561,68 +743,15 @@ class Budget:
561
743
 
562
744
  """
563
745
  if engine is None:
564
- engine = self._create_engine()
746
+ engine = database.Engine(self.config_path)
565
747
  logger.info("Updating labels in database.")
566
- rows_updated = 0
567
- with engine.connect() as conn:
568
- for _, row in tx_df.iterrows():
569
- if pd.isna(row[label_column]):
570
- update_label_query = sa.text(
571
- """
572
- UPDATE public.transactions_table
573
- SET "label" = NULL
574
- WHERE id = :id;
575
- """
576
- )
577
- executed = conn.execute(update_label_query, {"id": row["id"]})
578
- else:
579
- update_label_query = sa.text(
580
- """
581
- UPDATE public.transactions_table
582
- SET "label" = :label
583
- WHERE id = :id;
584
- """
585
- )
586
- executed = conn.execute(
587
- update_label_query,
588
- {"label": row[label_column], "id": row["id"]},
589
- )
590
- rows_updated = rows_updated + executed.rowcount
591
- conn.commit()
592
- logger.info(f"Rows updated: {rows_updated}")
593
-
594
- def _create_engine(self):
595
- """
596
- Create an engine.
597
748
 
598
- Returns
599
- -------
600
- engine : SQLAlchemy engine
601
- The engine to connect to the database.
749
+ # filter the table
750
+ tx_df = tx_df[[label_column, "id"]]
602
751
 
603
- """
604
- db_user = self.config_dict.get("db_user", None)
605
- db_pass = self.config_dict.get("db_pass", None)
606
- db_host = self.config_dict.get("db_host", None)
607
- db_name = self.config_dict.get("db_name", None)
608
- db_port = self.config_dict.get("db_port", None)
609
-
610
- config_values = {
611
- "db_user": db_user,
612
- "db_pass": db_pass,
613
- "db_host": db_host,
614
- "db_name": db_name,
615
- "db_port": db_port,
616
- }
617
- missing_values = [key for key, value in config_values.items() if value is None]
618
- if missing_values:
619
- raise ValueError(
620
- f"The following configuration values are missing: "
621
- f"{', '.join(missing_values)}"
622
- )
623
-
624
- logger.info("Connecting to database.")
625
- engine = sa.create_engine(
626
- f"postgresql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}"
752
+ tx_df = tx_df.rename(columns={label_column: "label"})
753
+ engine.bulk_update(
754
+ tx_df=tx_df, table_name="transactions_table", where_column="id"
627
755
  )
628
- return engine
756
+
757
+ logger.info(f"Rows updated: {len(tx_df)}")