Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## [v1.5.1](https://github.com/jkoestner/folioflex/tree/v1.5.1)

[Full Changelog](https://github.com/jkoestner/folioflex/compare/v1.5.0...v1.5.1)

**Enhancements**
- enhance mobile dashboard layout
- use skeleton loading on dashboard instead of dots
- update subscriptions to identify only active subscriptions

**Documentation**

**Bug Fixes**
- fix s&p heatmap

## [v1.5.0](https://github.com/jkoestner/folioflex/tree/v1.5.0)

[Full Changelog](https://github.com/jkoestner/folioflex/compare/v1.4.0...v1.5.0)
Expand Down
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,27 +76,27 @@ To install, this repository can be installed by running the following command in
the environment of choice.

```
pip install folioflex
uv pip install folioflex
```

Other options can be installed if using more functionality

```
pip install folioflex
pip install folioflex[dev] # if needing to develop or lint
uv pip install folioflex
uv pip install folioflex[dev] # if needing to develop or lint
``````

Or could be done using GitHub.

```
pip install git+https://github.com/jkoestner/folioflex.git
uv pip install git+https://github.com/jkoestner/folioflex.git
```

If wanting to do more and develop on the code, the following command can be run to install the packages in the requirements.txt file.

```
pip install -e .
pip install -e .[dev]
uv sync
uv sync --extra dev
```

### Docker Install
Expand Down Expand Up @@ -164,11 +164,9 @@ When using the portfolio class, the following code can be used to get the return

```python
from folioflex.portfolio.portfolio import Portfolio

config_path = "portfolio_demo.yml"
pf = Portfolio(
config_path=config_path,
portfolio='company_a'
)
pf = Portfolio(config_path=config_path, portfolio="company_a")
pf.get_performance()
```

Expand Down Expand Up @@ -224,6 +222,7 @@ python -m ipykernel install --user --name=folioflex
If wanting to get more detail in output of messages the logging can increased
```python
from folioflex.utils import config_helper

config_helper.set_log_level("DEBUG")
```

Expand Down
99 changes: 75 additions & 24 deletions folioflex/budget/budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def get_transactions(
how="left",
suffixes=[None, "_tmp"],
)
tx_df = tx_df.drop(columns=["id_tmp"])
tx_df = pd.merge(
tx_df,
item_df[["id", "plaid_institution_id", "user_id"]],
Expand All @@ -120,6 +121,7 @@ def get_transactions(
how="left",
suffixes=[None, "_tmp"],
)
tx_df = tx_df.drop(columns=["id_tmp"])
tx_df = pd.merge(
tx_df,
user_df[["id", "username"]],
Expand All @@ -128,6 +130,7 @@ def get_transactions(
how="left",
suffixes=[None, "_tmp"],
)
tx_df = tx_df.drop(columns=["id_tmp"])
if user is not None:
tx_df = tx_df[tx_df["username"] == user]
tx_df = tx_df[
Expand All @@ -151,7 +154,7 @@ def get_transactions(
logger.info(f"Number of transactions: {len(tx_df)}")
logger.info(
f"Number of transactions that are pending: "
f"{len(tx_df[tx_df['pending']==True])}"
f"{len(tx_df[tx_df['pending'] == True])}"
)

return tx_df
Expand Down Expand Up @@ -374,18 +377,36 @@ def modify_amazon_purchase_desc(

return amazon_df

def identify_subscriptions(self, tx_df: pd.DataFrame) -> pd.DataFrame:
def identify_subscriptions(
self,
tx_df: pd.DataFrame,
active_only: bool = True,
as_of: Optional[Any] = None,
active_grace: float = 1.5,
) -> pd.DataFrame:
"""
Identify possible subscriptions in the transactions.

The subscriptions are identified by the name and also the amount. If
the amount is similar for multiple transactions, and there are at least
a set amount of transcations, then it is likely a subscription.

A subscription is active if its days_since_last_charge is more recent
than the mean interval * grace.

Parameters
----------
tx_df : DataFrame
The transactions to identify subscriptions for.
active_only : bool
Whether to drop subscriptions that are no longer being charged.
as_of : str or datetime, optional
The date to measure activity against. Defaults to the most recent
transaction in `tx_df`.
active_grace : float
How many billing intervals a subscription may go unpaid before it
counts as inactive, e.g. 1.5 lets a monthly subscription run about
two weeks late.

Returns
-------
Expand All @@ -397,6 +418,21 @@ def identify_subscriptions(self, tx_df: pd.DataFrame) -> pd.DataFrame:
min_transactions = 3
interval_std_threshold = 5
amount_std_threshold = 0.1
columns = [
"Description",
"Occurrences",
"Mean Interval (Days)",
"Amount Mean",
"Amount Std Dev",
"Last Date",
"Last Amount",
"Days Since Last",
"Active",
]

tx_df = tx_df.copy()
tx_df["date"] = pd.to_datetime(tx_df["date"])
as_of = tx_df["date"].max() if as_of is None else pd.to_datetime(as_of)

# group data by name
grouped_df = tx_df.groupby("name")
Expand All @@ -407,6 +443,9 @@ def identify_subscriptions(self, tx_df: pd.DataFrame) -> pd.DataFrame:
if len(group) < min_transactions:
continue

# get_transactions returns newest first, so sort before diffing
group = group.sort_values("date")

# calculating the intervals and ensure they are regular
intervals = group["date"].diff().dropna().dt.days
regular = intervals.std() <= interval_std_threshold
Expand All @@ -415,27 +454,39 @@ def identify_subscriptions(self, tx_df: pd.DataFrame) -> pd.DataFrame:
amount_mean = group["amount"].mean()
if amount_mean == 0:
continue
relative_std = group["amount"].std() / amount_mean
# abs() so that a refund (negative mean) cannot flip the ratio
# negative and pass the threshold no matter how much it varies
relative_std = group["amount"].std() / abs(amount_mean)
consistent_amount = relative_std <= amount_std_threshold

# get the last date and amount
last_date = group["date"].max()
last_amount = group[group["date"] == last_date]["amount"].values[0]

if regular and consistent_amount:
subscriptions.append(
{
"Description": name,
"Occurrences": len(group),
"Mean Interval (Days)": intervals.mean(),
"Amount Mean": group["amount"].mean(),
"Amount Std Dev": group["amount"].std(),
"Last Date": last_date,
"Last Amount": last_amount,
}
)

subscriptions_df = pd.DataFrame(subscriptions)
if not (regular and consistent_amount):
continue

# a subscription in inactive if the last charge is more than
# the mean_interval * grace
mean_interval = intervals.mean()
last_date = group["date"].iloc[-1]
last_amount = group["amount"].iloc[-1]
days_since_last = (as_of - last_date).days

subscriptions.append(
{
"Description": name,
"Occurrences": len(group),
"Mean Interval (Days)": mean_interval,
"Amount Mean": amount_mean,
"Amount Std Dev": group["amount"].std(),
"Last Date": last_date,
"Last Amount": last_amount,
"Days Since Last": days_since_last,
"Active": days_since_last <= mean_interval * active_grace,
}
)

# columns are set so that a sort doesn't raise an error
subscriptions_df = pd.DataFrame(subscriptions, columns=columns)
if active_only:
subscriptions_df = subscriptions_df[subscriptions_df["Active"].astype(bool)]
subscriptions_df = subscriptions_df.sort_values(
by="Occurrences", ascending=False
)
Expand Down Expand Up @@ -506,9 +557,9 @@ def budget_view(

# calculating the amount remaining or over budget that has been spent
budget_df["remaining_budget"] = budget_df.apply(
lambda row: min(row["budget"], row["amount_diff"])
if row["amount_diff"] >= 0
else 0,
lambda row: (
min(row["budget"], row["amount_diff"]) if row["amount_diff"] >= 0 else 0
),
axis=1,
)
budget_df["over_budget"] = budget_df["amount_diff"].apply(
Expand Down
4 changes: 2 additions & 2 deletions folioflex/chatbot/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ class G4FProvider(ChatBotProvider):
"""

def get_chatbot(
self, g4f_provider=g4f.Provider.bing, auth=False, access_token=None
self, g4f_provider=g4f.Provider.BaseProvider, auth=False, access_token=None
):
"""
G4F chatbot.
Expand Down Expand Up @@ -325,7 +325,7 @@ def get_chatbot(self):

return self.chatbot

def get_query(self, query, scrape_url=None, model="gpt-4-1106-preview", **kwargs):
def get_query(self, query, scrape_url=None, model="gpt-5-nano", **kwargs):
"""
Get query from chatbot.

Expand Down
Loading
Loading