-
-
Save thcipriani/9cd0a0e1e6d42fe27483d0a84b7de33d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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