├── README.md ├── WTFPL.txt ├── ledgerrc ├── report ├── reportgraph.py └── script.gnuplot /README.md: -------------------------------------------------------------------------------- 1 | # ledger-reports 2 | Generate informational graphs from ledger-cli accounting files. 3 | 4 | ## reportgraph.py 5 | 6 | Generate a directed graph (in GraphViz markup) of how money moves within 7 | the file. 8 | 9 | May not work with multiple commodities, fixes are welcome. 10 | 11 | ## report 12 | 13 | Creates multiple interesting graphs from the data via [gnuplot](http://www.gnuplot.info/) 14 | and [piechart](https://github.com/cbdevnet/piechart/). 15 | 16 | Designed to be easily extendable (by editing it) with queries interesting to yourself. 17 | 18 | ### Example outputs 19 | 20 | ![A pie chart, showing asset split](https://raw.githubusercontent.com/cbdevnet/ledger-reports/assets/assets/AssetsOverview-Pie.svg?sanitize=true) 21 | 22 | ![A line chart, showing monthly expenses](https://raw.githubusercontent.com/cbdevnet/ledger-reports/assets/assets/Expenses-Monthly.svg?sanitize=true) 23 | 24 | ![A bar chart, showing monthly food expenses](https://raw.githubusercontent.com/cbdevnet/ledger-reports/assets/assets/Food-Monthly.svg?sanitize=true) 25 | 26 | ### Requirements 27 | 28 | * At least ledger 3 29 | * gnuplot 30 | * piechart 31 | * The files `ledgerrc` and `script.gnuplot` from this repository 32 | 33 | ### Usage 34 | 35 | Create a directory `reports/`. 36 | 37 | Run as `./report [periods ]` 38 | 39 | If no ledger file is specified, `main.ledger` is assumed. 40 | 41 | The resulting graphs will be placed in the reports/ directory. 42 | 43 | The script uses the following categories in the current state 44 | * `Assets` 45 | * `Assets:Liquid` 46 | * `Expenses` 47 | * `Expenses:Food` 48 | * `Liabilities` 49 | * `Income` 50 | 51 | If your ledger file uses a different layout or you want to create new 52 | graphs, you will have to edit the `report` script. The drawing tool invocations 53 | are abstracted away to a large degree via bash functions. 54 | 55 | Note that the `ledgerrc` file currently contains the *strict* option, 56 | which requires that all accounts and commodities be pre-announced. 57 | If you don't want that, remove the option from the file. 58 | 59 | ### The `periodic` feature 60 | 61 | The periodic feature is experimental and might not work exactly as expected. 62 | 63 | Reporting periods must be exactly equal-width buckets, meaning 64 | that the query result must have the exact same number of lines. 65 | 66 | This also includes things such as dates without a transaction (a problem 67 | which may be sidestepped by using `--empty`) and leap years, 68 | making `--daily` probably a bad window. `--weekly --empty` is probably a good 69 | minimum interval. 70 | 71 | The periodic report will create the same type of graphs from 72 | the same queries as the regular report, but will try to 73 | plot the data for every period as its own line. 74 | 75 | Data which can not reliably be segmented into equal-width 76 | periods should be plotted with the `\_aperiodic` functions, 77 | which always produce the same plot. 78 | 79 | ### Example invocations 80 | 81 | `./report` 82 | 83 | `./report whatever.ledger` 84 | 85 | `./report main.ledger periods 2015 2016` 86 | -------------------------------------------------------------------------------- /WTFPL.txt: -------------------------------------------------------------------------------- 1 | This program is free software. It comes without any warranty, to 2 | the extent permitted by applicable law. You can redistribute it 3 | and/or modify it under the terms of the Do What The Fuck You Want 4 | To Public License, Version 2, as published by Sam Hocevar and 5 | reproduced below. 6 | 7 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 8 | Version 2, December 2004 9 | 10 | Copyright (C) 2004 Sam Hocevar 11 | 12 | Everyone is permitted to copy and distribute verbatim or modified 13 | copies of this license document, and changing it is allowed as long 14 | as the name is changed. 15 | 16 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 17 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 18 | 19 | 0. You just DO WHAT THE FUCK YOU WANT TO. -------------------------------------------------------------------------------- /ledgerrc: -------------------------------------------------------------------------------- 1 | --input-date-format %Y-%m-%d 2 | --date-format %Y-%m-%d 3 | --sort date 4 | --no-pager 5 | --strict 6 | --no-color 7 | -------------------------------------------------------------------------------- /report: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | ## Generate ledger-cli reports 6 | # Requires at least ledger 3 7 | # Other prerequisites 8 | # gnuplot and the gnuplot scripts from this repo 9 | # piechart (https://github.com/cbdevnet/piechart) 10 | # A matching ledgerrc (see this repo) 11 | 12 | # Run as ./report [periods ] 13 | # Resulting graphs will be placed in the reports/ directory 14 | # The script uses the following categories 15 | # Assets 16 | # Assets:Liquid 17 | # Expenses 18 | # Expenses:Food 19 | # Liabilities 20 | # Income 21 | 22 | # The periodic feature is experimental and might not work exactly as expected 23 | # Reporting periods must be exactly equal-width buckets, meaning 24 | # that the query result must have the exact same number of lines. 25 | # This also includes things such as dates without a transaction (a problem 26 | # which may be sidestepped by using --empty) and leap years, 27 | # making --daily probably a bad window. --weekly --empty is probably a good 28 | # minimum interval. 29 | # The periodic report will create the same type of graphs from 30 | # the same queries as the regular report, but will try to 31 | # plot the data for every period as its own line. 32 | # Data which can not reliably be segmented into equal-width 33 | # periods should be plotted with the _aperiodic functions, 34 | # which always produce the same plot. 35 | 36 | # Assume ledger file if none given 37 | export LEDGER_FILE=${1:-"main.ledger"} 38 | export MODE=${2:-"complete"} 39 | 40 | # Get current date and ledger command 41 | DATE=$(date +%Y%m%d) 42 | FILEPREFIX="$DATE" 43 | LEDGER="ledger --init-file ./ledgerrc" 44 | 45 | # Store all reporting periods when requested 46 | if [ "$MODE" == "periods" ]; then 47 | DATADIR=$(mktemp -d) 48 | FILEPREFIX="$FILEPREFIX-Periodic" 49 | PERIODS=() 50 | while [ -n "$3" ]; do 51 | PERIODS+=($3) 52 | shift 53 | done 54 | 55 | printf "Report range: %s\n" "${PERIODS[@]}" 56 | fi 57 | 58 | plot_stdin(){ 59 | # The gnuplot scripts accept the following parameters 60 | # SVGTITLE Title of the SVG document 61 | # (and, in periodic reports, the data filename) 62 | # TITLE Title of the image 63 | # LEGEND Chart legend text 64 | 65 | printf "%s" "$1" | xargs $LEDGER | gnuplot script.gnuplot -e "plot ' "$DATADIR/$SVGTITLE.gnuplot" 70 | touch "$DATADIR/$SVGTITLE.data.in" 71 | touch "$DATADIR/$SVGTITLE.data" 72 | LINE=2 73 | for period in "${PERIODS[@]}"; do 74 | LINEPREFIX=",\\" 75 | if [ "$LINE" == "2" ]; then 76 | LINEPREFIX="\\" 77 | fi 78 | printf -- "--period %s %s" "$period" "$1" | xargs $LEDGER | paste "$DATADIR/$SVGTITLE.data.in" - > "$DATADIR/$SVGTITLE.data" 79 | printf '%s\n"%s" using 1:%s title "%s" with %s' "$LINEPREFIX" "$DATADIR/$SVGTITLE.data" "$LINE" "$LEGEND [$period]" "$2" >> "$DATADIR/$SVGTITLE.gnuplot" 80 | LINE=$(($LINE + 2)) 81 | cp "$DATADIR/$SVGTITLE.data" "$DATADIR/$SVGTITLE.data.in" 82 | done 83 | gnuplot script.gnuplot "$DATADIR/$SVGTITLE.gnuplot" 84 | } 85 | 86 | # Can only create periodic graphs from periodic data 87 | line_aperiodic(){ 88 | plot_stdin "$1" 'linespoints' 89 | } 90 | 91 | line(){ 92 | if [ "$MODE" == "periods" ]; then 93 | plot_periodic "$1" "linespoints" 94 | else 95 | plot_stdin "$1" 'linespoints' 96 | fi 97 | } 98 | 99 | bar(){ 100 | if [ "$MODE" == "periods" ]; then 101 | plot_periodic "$1" "boxes" 102 | else 103 | plot_stdin "$1" 'boxes' 104 | fi 105 | } 106 | 107 | SVGTITLE="WeeklyAssets" TITLE="Assets, weekly" LEGEND="Assets" \ 108 | line "--total-data --weekly --collapse --empty --market register ^Assets" > reports/$FILEPREFIX-Assets-Weekly.svg 109 | SVGTITLE="Assets" TITLE="Assets, all TX" LEGEND="Assets" \ 110 | line_aperiodic "--total-data --collapse --market register ^Assets" > reports/$FILEPREFIX-Assets.svg 111 | SVGTITLE="Equity" TITLE="Equity, weekly" LEGEND="Liquid Assets vs Liabilities" \ 112 | line "--total-data --collapse --empty --weekly register ^Assets:Liquid ^Liabilities" > reports/$FILEPREFIX-Equity.svg 113 | 114 | SVGTITLE="Balance" TITLE="Monthly Balance" LEGEND="Expenses (Positive implies overspending)" \ 115 | bar "--amount-data --collapse --empty --monthly register ^Income ^Expenses" > reports/$FILEPREFIX-Balance-Monthly.svg 116 | 117 | SVGTITLE="Food" TITLE="Food Expenses" LEGEND="Expenses:Food" \ 118 | bar "--amount-data --collapse --monthly --empty register ^Expenses:Food" > reports/$FILEPREFIX-Food-Monthly.svg 119 | 120 | SVGTITLE="Expenses" TITLE="Monthly Expenses" LEGEND="Expenses" \ 121 | line "--amount-data --collapse --monthly --empty register ^Expenses" > reports/$FILEPREFIX-Expenses-Monthly.svg 122 | SVGTITLE="Income" TITLE="Monthly Income" LEGEND="Income" \ 123 | line "--amount-data --collapse --monthly --empty register ^Income" > reports/$FILEPREFIX-Income-Monthly.svg 124 | 125 | $LEDGER --depth 2 --balance-format "%-(to_int(display_total))|%-(partial_account)\n" --sort amount --no-total balance ^Expenses | tail -n +2 | piechart --delimiter "|" --order value,legend --color random > reports/$FILEPREFIX-Expense-Pie.svg 126 | $LEDGER --depth 2 --balance-format "%-(to_int(display_total))|%-(partial_account)\n" --sort amount --market --no-total balance ^Assets | tail -n +2 | piechart --delimiter "|" --order value,legend --color random > reports/$FILEPREFIX-AssetsOverview-Pie.svg 127 | $LEDGER --related --balance-format "%-(to_int(display_total))|%-(partial_account)\n" --sort amount --no-total balance ^Income | tail -n +2 | piechart --delimiter "|" --order value,legend --color random > reports/$FILEPREFIX-IncomeTargets-Pie.svg 128 | 129 | if [ -n "$DATADIR" ]; then 130 | rm -rf "$DATADIR" 131 | fi 132 | -------------------------------------------------------------------------------- /reportgraph.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #Run using ledger --no-pager --no-color -F '%C|%(t*100)|%A\n' -f register | ./reportgraph.py | circo -Tsvg > out.svg 3 | 4 | import fileinput 5 | import re 6 | import sys 7 | 8 | class Entry: 9 | ident = "" 10 | amount = 0 11 | account = "" 12 | def __init__(self, amount, account, ident = "NONE"): 13 | self.amount = int(amount) 14 | self.account = account 15 | self.ident = ident 16 | def dump(self): 17 | print >> sys.stderr, '%s: %s @ %s' % (self.ident, self.amount, self.account) 18 | 19 | class Transaction: 20 | amount = 0 21 | source = "" 22 | sink = "" 23 | 24 | def __init__(self, amount, source, sink): 25 | self.amount = amount 26 | self.source = source 27 | self.sink = sink 28 | 29 | def dump(self): 30 | print >> sys.stderr, '%s %s -> %s' % (self.amount, self.source, self.sink) 31 | 32 | class Edge: 33 | amount = 0 34 | tx = 0 35 | sink = 0 36 | 37 | def __init__(self, sink): 38 | self.amount = 0 39 | self.tx = 0 40 | self.sink = sink 41 | 42 | def dump(self, source): 43 | print '%d -> %d [ label="%d in %d Transactions" ];' % (source, self.sink, self.amount, self.tx) 44 | 45 | 46 | class Node: 47 | name = "" 48 | amount_in = 0 49 | tx_in = 0 50 | outbound = {} 51 | node_id = 0 52 | 53 | def __init__(self, name, node_id): 54 | self.name = name 55 | self.amount_in = 0 56 | self.tx_in = 0 57 | self.node_id = node_id 58 | self.outbound = {} 59 | 60 | def txout(self): 61 | return len(self.outbound) 62 | 63 | def amountout(self): 64 | sum = 0 65 | for tx in self.outbound.keys(): 66 | sum += self.outbound[tx].amount 67 | return sum 68 | 69 | def dump(self): 70 | print '%s [label="%s @ %d\\nIn: %d Transactions @ %d\\nOut: %d Transactions @ %d)"];' % (self.node_id, self.name, self.amount_in - self.amountout(), self.tx_in, self.amount_in, self.txout(), self.amountout()) 71 | for edge in self.outbound.keys(): 72 | self.outbound[edge].dump(self.node_id) 73 | 74 | def txfromentries(entries): 75 | tx = [] 76 | negatives = [] 77 | positives = [] 78 | for entry in entries: 79 | if entry.amount < 0: 80 | negatives.append(entry) 81 | else: 82 | positives.append(entry) 83 | 84 | if len(negatives) == 1: 85 | for pos in positives: 86 | tx.append(Transaction(pos.amount, negatives[0].account, pos.account)) 87 | return tx 88 | 89 | if len(positives) == 1: 90 | for neg in negatives: 91 | tx.append(Transaction(neg.amount, neg.account, positives[0].account)) 92 | return tx 93 | 94 | print >> sys.stderr, 'Entry could not be reliably resolved into transactions:' 95 | for entr in entries: 96 | entr.dump() 97 | return [] 98 | 99 | def balance(entries): 100 | balance = 0 101 | for entry in entries: 102 | balance += entry.amount 103 | return balance 104 | 105 | def balances(entries): 106 | return balance(entries) == 0 107 | 108 | entries = {} 109 | transactions = [] 110 | nodes = {} 111 | expr = re.compile("^(?P.*)\|(?P.*)\|(?P.*)$") 112 | 113 | # read entries 114 | for line in fileinput.input(): 115 | match = re.match(expr, line) 116 | #print '%s @ %s -> %s' % (match.group("tag"), match.group("amount"), match.group("account")) 117 | if match.group("tag") == "": 118 | continue 119 | 120 | if entries.get(match.group("tag"), None) is None: 121 | entries[match.group("tag")] = [] 122 | entries[match.group("tag")].append(Entry(match.group("amount"), match.group("account"), match.group("tag"))) 123 | 124 | #try to match transactions 125 | for transaction in entries.keys(): 126 | if not balances(entries[transaction]): 127 | print 'Transaction %s does not balance: %s' % (transaction, balance(entries[transaction])) 128 | for entry in entries[transaction]: 129 | entry.dump() 130 | sys.exit(1) 131 | transactions.extend(txfromentries(entries[transaction])) 132 | 133 | #aggregate per source/sink couple 134 | node_ids = 0 135 | for tx in transactions: 136 | if nodes.get(tx.source, None) is None: 137 | nodes[tx.source] = Node(tx.source, node_ids) 138 | node_ids += 1 139 | if nodes.get(tx.sink, None) is None: 140 | nodes[tx.sink] = Node(tx.sink, node_ids) 141 | node_ids += 1 142 | if nodes[tx.source].outbound.get(tx.sink, None) is None: 143 | nodes[tx.source].outbound[tx.sink] = Edge(nodes[tx.sink].node_id) 144 | 145 | nodes[tx.source].outbound[tx.sink].amount += tx.amount 146 | nodes[tx.source].outbound[tx.sink].tx += 1 147 | nodes[tx.sink].amount_in += tx.amount 148 | nodes[tx.sink].tx_in += 1 149 | 150 | #dump 151 | print 'digraph Money {' 152 | for node in nodes.keys(): 153 | nodes[node].dump() 154 | print '}' 155 | -------------------------------------------------------------------------------- /script.gnuplot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/gnuplot 2 | TITLE = system("echo $TITLE") 3 | LEGEND = system("echo $LEGEND") 4 | SVGTITLE = system("echo $SVGTITLE") 5 | set term svg size 1920,1080 enhanced font "Biolinum,10" name "".SVGTITLE 6 | set grid 7 | set xdata time 8 | set title "".TITLE 9 | set timefmt "%Y-%m-%d" 10 | set format x "%Y-%m-%d" 11 | set autoscale 12 | set xzeroaxis linewidth 2 13 | set boxwidth 0.95 relative 14 | set style fill solid 0.25 border 15 | set style line 1 lw 1 lc rgb "blue" 16 | set style line 2 lw 1 lc rgb "red" 17 | set style line 3 lw 1 lc rgb "black" 18 | set style line 4 lw 1 lc rgb "sea-green" 19 | set style increment user 20 | --------------------------------------------------------------------------------