Skip to content

Instantly share code, notes, and snippets.

@thcipriani
Created November 13, 2024 02:13
Show Gist options
  • Save thcipriani/9cd0a0e1e6d42fe27483d0a84b7de33d to your computer and use it in GitHub Desktop.
Save thcipriani/9cd0a0e1e6d42fe27483d0a84b7de33d to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# cashflow.sh - Plot cashflow using gnuplot
# Usage: cashflow.sh
# Dependencies: hledger, gnuplot, awk
# Author: <https://www.sundialdreams.com/report-scripts-for-ledger-cli-with-gnuplot/> + minor modifications
set -euo pipefail
if ! command -v gnuplot &> /dev/null; then
echo "gnuplot is not installed"
exit 1
fi
income=$(mktemp)
expense=$(mktemp)
last_year=$(date --date "last year" +%Y)
this_year=$(date +%Y)
trap 'rm -f "$income" "$expense"' EXIT
hledger register income -b "${this_year}-01-01" -M --output-format csv | awk -F, '
NR > 1 {
gsub(/"/, "", $2);
gsub(/[\$,"]/, "", $7);
amount = ($7 < 0 ? -$7 : $7);
totals[$2] = amount;
}
END {
for (date in totals)
printf "%s %.2f\n", date, totals[date];
}
' | sort > "$income"
hledger register expenses -b "${this_year}-01-01" -M --output-format csv | awk -F, '
NR > 1 {
gsub(/"/, "", $2);
gsub(/[\$,"]/, "", $7);
amount = ($7 < 0 ? -$7 : $7);
totals[$2] = amount;
}
END {
for (date in totals)
printf "%s %.2f\n", date, totals[date];
}
' | sort > "$expense"
cat <(echo "INCOME") "$income" <(echo "EXPENSE") "$expense"
(cat <<EOF) | gnuplot
set terminal qt size 1280,720 persist
big_font = "League Spartan,12"
small_font = "Source Code Pro,9"
text_color = "#444444"
border_color = "#f2f2f2"
text_formatting(x, y) = (strlen(x) > 0) ? sprintf('%s: $%.0f', x, y) : sprintf('$%.0f', y)
boxwidth = 1.5
stats '$expense' using 2 nooutput name "expense"
stats '$income' using 2 nooutput name "income"
overall_max = (int(expense_max) > int(income_max)) ? int(expense_max) : int(income_max)
set xdata time
set key left top nobox font small_font textcolor rgb text_color
set timefmt "%Y-%m-%d"
set xrange ["${last_year}-12-20":"${this_year}-12-10"]
set yrange [0:overall_max * 1.05]
set xtics nomirror "$(date +%Y)-01-01",2592000 format "%b"
unset mxtics
set border lw 1 lc rgb border_color
set xtics nomirror font small_font textcolor rgb text_color
set title "INCOME VS EXPENSES\n{/*0.75(cumulative)" font big_font textcolor rgb text_color
set style fill transparent solid 0.4 noborder
set noylabel
unset ytic
unset ylabel
plot "$income" using 1:2 with filledcurves x1 title "Income" linecolor rgb "seagreen", \
'' using 1:2:(text_formatting('', column(2))) with labels font small_font offset 0,0.5 textcolor linestyle 0 notitle, \
"$expense" using 1:2 with filledcurves y1=0 title "Expenses" linecolor rgb "light-salmon", \
'' using 1:2:(text_formatting('', column(2))) with labels font small_font offset 0,-0.5 textcolor linestyle 0 notitle
EOF
# Load required libraries
library(ggplot2)
library(stringr)
library(readr)
library(dplyr)
library(tidyr)
library(reshape2)
library(scales)
library(systemfonts)
library(showtext)
font_family <- "roboto"
showtext_auto()
font_add_google("Roboto", "roboto")
font_add_google("Redacted Script", "redacted")
# last full month
last_month <- as.character(as.Date(cut(Sys.Date(), "month")) - 1)
last_month_full_name <- format(as.Date(last_month), "%B %Y")
# Drop the day from the iso date
# 2024-10-01 -> 2024-10
last_month_col <- substr(last_month, 1, 7)
# Load and reshape the data
data <- read_csv("expenses.csv")
data <- data %>% melt(id.vars = c("account", "average"), variable.name = "month", value.name = "expense")
# Strip '$'
data$expense <- gsub("\\$", "", data$expense)
data$average <- gsub("\\$", "", data$average)
# Convert float
data$expense <- as.numeric(data$expense)
data$average <- as.numeric(data$average)
data$account <- str_to_title(gsub("expenses:", "", data$account))
# Drop the "Unknown" row
data <- data %>% filter(account != "Unknown")
data <- data %>% filter((month == last_month_col))
data <- data %>% arrange(desc(expense))
# Rename the month to the month short name
data$month <- sprintf("%s-%s", data$month, "01")
data$month <- format(as.Date(data$month), "%b %Y")
head(data)
data <- data[1:4,]
data$account <- factor(data$account, levels = rev(data$account))
scale <- max(data$expense) * 1.1
ten_percent <- scale * 0.005
col_left_pos <- scale - scale * 0.3
col_right_pos <- scale - scale * 0.2
options(repr.plot.height=4)
width = 0.8
ggplot(data, aes(x = account, y = expense)) +
geom_col(fill = "steelblue", alpha=0.1, width = 1) +
geom_text(aes(label = account, y = ten_percent*2), hjust = "inward", size = 8) +
geom_text(aes(label = label_dollar(accuracy=1)(expense), y = col_right_pos), hjust = 0, size = 6.25, color = "#222222") +
geom_text(aes(label = label_dollar(accuracy=1)(average), y = col_left_pos), hjust = 0, size = 6, color = "#333333") +
# geom_text(aes(label = '$0000.00', y = col_right_pos), hjust = 0, size = 6.25, color = "#222222", family="redacted") +
# geom_text(aes(label = '$0000.00', y = col_left_pos), hjust = 0, size = 6, color = "#333333", family="redacted") +
geom_errorbar(aes(ymin=0, ymax=0), color = "steelblue", linewidth=1.1) +
scale_linetype_manual(values = c("Average" = "dotted")) +
geom_errorbar(aes(ymin=average, ymax=average, group=account, linetype="Average"), color = "steelblue", show.legend=TRUE) +
annotate("text", label = 'CATEGORY', x=4.75, y = ten_percent*2, hjust = "inward", size = 3, fontface = "bold") +
annotate("text", label = 'AMOUNT', x =4.75, y = col_right_pos, hjust = 0, size = 3, fontface = "bold") +
annotate("text", label = 'AVG', x = 4.75, y = col_left_pos + 20, hjust=0, size = 3, fontface = "bold") +
scale_y_discrete(expand = c(0, 0)) +
coord_flip(clip="off") +
labs(title = "Expenses",
subtitle = last_month_full_name,
linetype = "",
x = NULL, y = NULL) +
theme_minimal() +
theme(
legend.position = c(0, -0.15),
legend.justification = c(0, 0),
legend.title = element_blank(),
legend.text = element_text(size = 10, family = font_family),
text = element_text(family = font_family, color="#212121"),
plot.title = element_text(vjust = 2, size = 32, face = "bold"),
plot.subtitle = element_text(vjust = 3.75, size = 18),
axis.text.x = element_blank(),
axis.ticks.x = element_blank(),
panel.grid = element_blank(),
axis.text.y = element_blank(),
aspect.ratio = 0.2,
plot.margin = margin(t = 1, r = 15, b = 20, l = 15),
)
ggsave("expenses_histogram.svg", width = 15, height = 4.75, limitsize = FALSE)
#!/usr/bin/env bash
# Get the hledger data
hledger bal -b 2024-01-01 -e 2024-11-01 expenses -M --depth=2 --no-total --average -O csv > expenses.csv
Rscript expenses.R
echo "Plot saved as expenses_histogram.svg"
#!/usr/bin/env bash
# plot-subaccounts.sh
# --------------------
# Setup: python3 -m virtualenv .venv && . .venv && pip install pandas
# Author: Tyler Cipriani, 2024
# License: GPLv3
set -eo pipefail
BASEDIR=$(cd "$(dirname "$0")" && pwd)
VENVDIR="${BASEDIR}/../.venv/bin"
show_help() {
cat<<USE
USAGE:
$0 [OPTIONS] [OUTPUT_FILE]
OPTIONS:
-m, --months Number of months to include in the chart (default: 5)
-a, --account Account to chart (default: expenses)
-d, --depth Depth of the account to chart (default: 2)
EXAMPLE:
$0 -m 3 expenses.png
USE
}
die() {
printf '[ERROR] %s\n' "$1" >&2
exit 1
}
hledger_bal() {
local last_month months_ago account depth
last_month=$1
months_ago=$2
account=$3
depth=$4
# Get the expenses for the last $months_ago months
hledger bal "$account" -b "$months_ago" -e "$last_month" -M --depth="$depth" -O csv --no-total --average | "${VENVDIR}"/python3 -c "
import sys
import pandas as pd
df = pd.read_csv(sys.stdin)
df['account'] = df['account'].str.split(':').str[-1].str.title()
for col in df.columns:
if col != 'account':
df[col] = df[col].str.replace(',', '').str.replace('\$', '').astype(float)
df = df.sort_values(by='average', ascending=False)
# df.drop(columns=['average'], inplace=True)
df = df.drop(columns=['average'])
# Pivot the data
df_pivoted = df.set_index('account').T
df_pivoted = df_pivoted.reset_index().rename(columns={'index': 'date'})
sorted_columns = ['date'] + list(df['account'])
df_pivoted = df_pivoted[sorted_columns]
df_pivoted.to_csv(sys.stdout, index=False)
"
}
plot_balances() {
local data_file plot_file term num_accounts layout_cols layout_rows layout
data_file="$1"
shift
if [ -z "$1" ]; then
term='qt size 1280,720 persist'
plot_file=
else
term='pngcairo size 1280,720'
plot_file="set output '$1'"
fi
num_accounts=$(head -n 1 "$data_file" | awk -F, '{print NF - 1}')
layout_cols=$(echo "sqrt($num_accounts)" | bc)
layout_rows=$layout_cols
while ((layout_cols * layout_rows < num_accounts)); do
layout_cols=$((layout_cols + 1))
if ((layout_cols * layout_rows < num_accounts)); then
layout_rows=$((layout_rows + 1))
fi
done
layout="$layout_cols,$layout_rows"
# Gnuplot script
(cat <<EOF) | gnuplot
set datafile separator ","
set key autotitle columnhead
unset key
set terminal $term
$plot_file
big_font = "League Spartan,12"
small_font = "Source Code Pro,9"
text_color = "#444444"
border_color = "#f2f2f2"
text_formatting(x, y) = (strlen(x) > 0) ? sprintf('%s: $%.0f', x, y) : sprintf('$%.0f', y)
boxwidth = 1.5
array mean[$num_accounts]
array max[$num_accounts]
do for [i=2:$num_accounts+1] {
stats '$data_file' using i nooutput
mean[i-1] = STATS_mean
max[i-1] = STATS_max
}
overall_max = 0
do for [i=1:$num_accounts] {
overall_max = max[i] > overall_max ? max[i] : overall_max
}
set style fill transparent solid 0.4 noborder
set style data histogram
set style histogram clustered gap 1
set boxwidth 1.5
set multiplot layout $layout
ymax = overall_max * 1.33
print overall_max
print ymax
set yrange [0:ymax]
set noylabel
set xtics nomirror
set ytics nomirror
set border lw 1 lc rgb border_color
set xtics font small_font textcolor rgb text_color
unset ylabel
unset ytic
set xrange [-2:$months+1]
do for [i=2:$num_accounts+1] {
column_title = word(system(sprintf("head -n 1 %s | awk -F, '{print toupper(\$%d)}'", "$data_file", i)), 1)
set label 1 column_title font big_font at graph 0.03, graph 0.9 left tc rgb text_color
set label 2 text_formatting('Avg', mean[i-1]) font small_font at graph 0.03, mean[i-1]/ymax offset 0,0.5 tc rgb text_color
plot '$data_file' using i:xtic(1) with histogram title col ls i-1, \
'' using 0:i:(text_formatting('', column(i))) w labels font small_font offset 0,0.5 noti, \
mean[i-1] noti with lines lw 1 dt 3
}
unset multiplot
EOF
}
main () {
if ! command -v gnuplot > /dev/null 2>&1; then
die "gnuplot is not installed"
fi
if ! command -v csvsql > /dev/null 2>&1; then
die "csvkit is not installed"
fi
if ! command -v "${VENVDIR}"/python3 > /dev/null 2>&1; then
die "python3 virtualenv is not initialized"
fi
local months depth account output_file data_file
months=6
depth=2
account=expenses
output_file=
while [ -n "$1" ]; do
case "$1" in
--months|-m)
shift
months="$1"
;;
--account|-a)
shift
account="$1"
;;
--depth|-d)
shift
depth="$1"
;;
--help|-h)
show_help
exit 0
;;
*)
output_file="$1"
;;
esac
shift
done
months=$(( months - 1 ))
local last_month months_ago
last_month=$(date -d "$(date +'%Y-%m-01') -1 day" +'%Y-%m-%d')
months_ago=$(date -d "${last_month} -$months months" +'%Y-%m-01')
data_file=$(mktemp)
hledger_bal "$last_month" "$months_ago" "$account" "$depth" > "$data_file"
cat "$data_file"
plot_balances "$data_file" "$output_file"
if [ -n "$output_file" ]; then
echo "Chart saved to $output_file"
fi
rm -f "$data_file"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment