add allocation target

This commit is contained in:
Anantha Kumaran 2022-07-10 13:40:22 +05:30
parent 41b0e1f66a
commit 30200d5ff4
11 changed files with 375 additions and 77 deletions

View File

@ -44,6 +44,15 @@ func generateConfigFile(cwd string) {
config := `
journal_path: "%s"
db_path: "%s"
allocation_targets:
- name: Debt
target: 40
accounts:
- Asset:Debt:*
- name: Equity
target: 60
accounts:
- Asset:Equity:*
commodities:
- name: NIFTY
type: mutualfund

View File

@ -10,3 +10,4 @@
# Reference Guide
- [Accounts](accounts.md)
- [Allocation Targets](allocation-targets.md)

View File

@ -0,0 +1,19 @@
# Allocation Targets
Paisa allows you to set a allocation target for a group of
accounts. The allocation page shows how far your current allocation is
from the allocation target. For example, to keep a 40:60 split between
debt and equity, add the following configuration to the `paisa.yaml`
file. The account name can have `*` which matches any characters
```yaml
allocation_targets:
- name: Debt
target: 40
accounts:
- Asset:Debt:*
- name: Equity
target: 60
accounts:
- Asset:Equity:*
```

2
go.mod
View File

@ -8,7 +8,7 @@ require (
github.com/google/btree v1.0.1
github.com/logrusorgru/aurora v2.0.3+incompatible
github.com/manifoldco/promptui v0.9.0
github.com/samber/lo v1.11.0
github.com/samber/lo v1.25.0
github.com/sirupsen/logrus v1.8.1
github.com/spf13/cobra v1.4.0
github.com/spf13/viper v1.10.1

4
go.sum
View File

@ -76,8 +76,8 @@ github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZ
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.11.0 h1:JfeYozXL1xfkhRUFOfH13ociyeiLSC/GRJjGKI668xM=
github.com/samber/lo v1.11.0/go.mod h1:2I7tgIv8Q1SG2xEIkRq0F2i2zgxVpnyPOP0d3Gj2r+A=
github.com/samber/lo v1.25.0 h1:H8F6cB0RotRdgcRCivTByAQePaYhGMdOTJIj2QFS2I0=
github.com/samber/lo v1.25.0/go.mod h1:2I7tgIv8Q1SG2xEIkRq0F2i2zgxVpnyPOP0d3Gj2r+A=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=

View File

@ -1,6 +1,7 @@
package server
import (
"path/filepath"
"strings"
"time"
@ -10,6 +11,7 @@ import (
"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/service"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"gorm.io/gorm"
)
@ -20,6 +22,19 @@ type Aggregate struct {
MarketAmount float64 `json:"market_amount"`
}
type AllocationTargetConfig struct {
Name string
Target float64
Accounts []string
}
type AllocationTarget struct {
Name string `json:"name"`
Target float64 `json:"target"`
Current float64 `json:"current"`
Aggregates map[string]Aggregate `json:"aggregates"`
}
func GetAllocation(db *gorm.DB) gin.H {
var postings []posting.Posting
result := db.Where("account like ?", "Asset:%").Order("date ASC").Find(&postings)
@ -34,7 +49,8 @@ func GetAllocation(db *gorm.DB) gin.H {
})
aggregates := computeAggregate(postings, now)
aggregates_timeline := computeAggregateTimeline(postings)
return gin.H{"aggregates": aggregates, "aggregates_timeline": aggregates_timeline}
allocation_targets := computeAllocationTargets(postings)
return gin.H{"aggregates": aggregates, "aggregates_timeline": aggregates_timeline, "allocation_targets": allocation_targets}
}
func computeAggregateTimeline(postings []posting.Posting) []map[string]Aggregate {
@ -55,6 +71,37 @@ func computeAggregateTimeline(postings []posting.Posting) []map[string]Aggregate
return timeline
}
func computeAllocationTargets(postings []posting.Posting) []AllocationTarget {
var targetAllocations []AllocationTarget
var configs []AllocationTargetConfig
viper.UnmarshalKey("allocation_targets", &configs)
totalMarketAmount := lo.Reduce(postings, func(acc float64, p posting.Posting, _ int) float64 { return acc + p.MarketAmount }, 0.0)
for _, config := range configs {
targetAllocations = append(targetAllocations, computeAllocationTarget(postings, config, totalMarketAmount))
}
return targetAllocations
}
func computeAllocationTarget(postings []posting.Posting, config AllocationTargetConfig, total float64) AllocationTarget {
date := time.Now()
postings = lo.Filter(postings, func(p posting.Posting, _ int) bool {
return lo.SomeBy(config.Accounts, func(accountGlob string) bool {
match, err := filepath.Match(accountGlob, p.Account)
if err != nil {
log.Fatal("Invalid account value used in target_allocations", accountGlob, err)
}
return match
})
})
aggregates := computeAggregate(postings, date)
currentTotal := lo.Reduce(postings, func(acc float64, p posting.Posting, _ int) float64 { return acc + p.MarketAmount }, 0.0)
return AllocationTarget{Name: config.Name, Target: config.Target, Current: (currentTotal / total) * 100, Aggregates: aggregates}
}
func computeAggregate(postings []posting.Posting, date time.Time) map[string]Aggregate {
byAccount := lo.GroupBy(postings, func(p posting.Posting) string { return p.Account })
result := make(map[string]Aggregate)

View File

@ -1,3 +1,4 @@
import $ from "jquery";
import * as d3 from "d3";
import legend from "d3-svg-legend";
import dayjs from "dayjs";
@ -5,6 +6,7 @@ import _ from "lodash";
import {
Aggregate,
ajax,
AllocationTarget,
formatCurrency,
formatFloat,
lastName,
@ -12,88 +14,282 @@ import {
rainbowScale,
secondName,
textColor,
tooltip
tooltip,
skipTicks
} from "./utils";
export default async function () {
const { aggregates: aggregates, aggregates_timeline: aggregatesTimeline } =
await ajax("/api/allocation");
const {
aggregates: aggregates,
aggregates_timeline: aggregatesTimeline,
allocation_targets: allocationTargets
} = await ajax("/api/allocation");
_.each(aggregates, (a) => (a.timestamp = dayjs(a.date)));
_.each(aggregatesTimeline, (aggregates) =>
_.each(aggregates, (a) => (a.timestamp = dayjs(a.date)))
);
renderAllocationTarget(allocationTargets);
renderAllocation(aggregates);
renderAllocationTimeline(aggregatesTimeline);
}
function renderAllocationTarget(allocationTargets: AllocationTarget[]) {
const id = "#d3-allocation-target";
if (_.isEmpty(allocationTargets)) {
$(id).closest(".container").hide();
return;
}
allocationTargets = _.sortBy(allocationTargets, (t) => t.name);
const BAR_HEIGHT = 25;
const svg = d3.select(id),
margin = { top: 20, right: 0, bottom: 10, left: 150 },
fullWidth = document.getElementById(id.substring(1)).parentElement
.clientWidth,
width = fullWidth - margin.left - margin.right,
height = allocationTargets.length * BAR_HEIGHT * 2,
g = svg
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
svg.attr("height", height + margin.top + margin.bottom);
const keys = ["target", "current"];
const colorKeys = ["target", "current", "diff"];
const colors = ["#1f77b4", "#17becf", "#4a4a4a"];
const y = d3.scaleBand().range([0, height]).paddingInner(0).paddingOuter(0);
y.domain(allocationTargets.map((t) => t.name));
const y1 = d3
.scaleBand()
.range([0, y.bandwidth()])
.domain(keys)
.paddingInner(0)
.paddingOuter(0.1);
const z = d3.scaleOrdinal<string>(colors).domain(colorKeys);
const maxX = _.chain(allocationTargets)
.flatMap((t) => [t.current, t.target])
.max()
.value();
const targetWidth = 400;
const targetMargin = 20;
const textGroupWidth = 150;
const textGroupMargin = 20;
const textGroupZero = targetWidth + targetMargin;
const x = d3
.scaleLinear()
.range([textGroupZero + textGroupWidth + textGroupMargin, width]);
x.domain([0, maxX]);
const x1 = d3.scaleLinear().range([0, targetWidth]).domain([0, maxX]);
g.append("line")
.attr("stroke", "#ddd")
.attr("x1", 0)
.attr("y1", height)
.attr("x2", width)
.attr("y2", height);
g.append("text")
.attr("fill", "#4a4a4a")
.text("Target")
.attr("text-anchor", "end")
.attr("x", textGroupZero + (textGroupWidth * 1) / 3)
.attr("y", -5);
g.append("text")
.attr("fill", "#4a4a4a")
.text("Current")
.attr("text-anchor", "end")
.attr("x", textGroupZero + (textGroupWidth * 2) / 3)
.attr("y", -5);
g.append("text")
.attr("fill", "#4a4a4a")
.text("Diff")
.attr("text-anchor", "end")
.attr("x", textGroupZero + textGroupWidth)
.attr("y", -5);
g.append("g")
.attr("class", "axis y")
.attr("transform", "translate(0," + height + ")")
.call(
d3
.axisBottom(x1)
.tickSize(-height)
.tickFormat(skipTicks(40, x, (n) => formatFloat(n, 0)))
);
g.append("g").attr("class", "axis y dark").call(d3.axisLeft(y));
const textGroup = g
.append("g")
.selectAll("g")
.data(allocationTargets)
.enter()
.append("g")
.attr("class", "inline-text");
textGroup
.append("line")
.attr("stroke", "#ddd")
.attr("x1", 0)
.attr("y1", (t) => y(t.name))
.attr("x2", width)
.attr("y2", (t) => y(t.name));
textGroup
.append("text")
.text((t) => formatFloat(t.target))
.attr("text-anchor", "end")
.attr("alignment-baseline", "middle")
.style("fill", z("target"))
.attr("x", textGroupZero + (textGroupWidth * 1) / 3)
.attr("y", (t) => y(t.name) + y.bandwidth() / 2);
textGroup
.append("text")
.text((t) => formatFloat(t.current))
.attr("text-anchor", "end")
.attr("alignment-baseline", "middle")
.style("fill", z("current"))
.attr("x", textGroupZero + (textGroupWidth * 2) / 3)
.attr("y", (t) => y(t.name) + y.bandwidth() / 2);
textGroup
.append("text")
.text((t) => formatFloat(t.current - t.target))
.attr("text-anchor", "end")
.attr("alignment-baseline", "middle")
.style("fill", z("diff"))
.attr("x", textGroupZero + (textGroupWidth * 3) / 3)
.attr("y", (t) => y(t.name) + y.bandwidth() / 2);
const groups = g
.append("g")
.selectAll("g.group")
.data(allocationTargets)
.enter()
.append("g")
.attr("class", "group")
.attr("transform", (t) => "translate(0," + y(t.name) + ")");
groups
.selectAll("g")
.data((t) => [
{ key: "target", value: t.target },
{ key: "current", value: t.current }
])
.enter()
.append("rect")
.attr("fill", (d) => {
return z(d.key);
})
.attr("x", x1(0))
.attr("y", (d) => y1(d.key))
.attr("height", y1.bandwidth())
.attr("width", (d) => x1(d.value));
const paddingTop = (y1.range()[1] - y1.bandwidth() * 2) / 2;
d3.select("#d3-allocation-target-treemap")
.append("div")
.style("height", height + margin.top + margin.bottom + "px")
.style("position", "absolute")
.style("width", "100%")
.selectAll("div")
.data(allocationTargets)
.enter()
.append("div")
.style("position", "absolute")
.style("left", margin.left + x(0) + "px")
.style("top", (t) => margin.top + y(t.name) + paddingTop + "px")
.style("height", y1.bandwidth() * 2 + "px")
.style("width", x.range()[1] - x.range()[0] + "px")
.append("div")
.style("position", "relative")
.attr("height", y1.bandwidth() * 2)
.each(function (t) {
renderPartition(this, t.aggregates, d3.treemap());
});
}
function renderAllocation(aggregates: { [key: string]: Aggregate }) {
const allocation = function (id, hierarchy) {
const div = d3.select("#" + id),
margin = { top: 0, right: 0, bottom: 0, left: 20 },
width =
document.getElementById(id).parentElement.clientWidth -
margin.left -
margin.right,
height = +div.attr("height") - margin.top - margin.bottom;
renderPartition(
document.getElementById("d3-allocation-category"),
aggregates,
d3.partition()
);
renderPartition(
document.getElementById("d3-allocation-value"),
aggregates,
d3.treemap()
);
}
const percent = (d) => {
return formatFloat((d.value / root.value) * 100) + "%";
};
function renderPartition(element: HTMLElement, aggregates, hierarchy) {
const div = d3.select(element),
margin = { top: 0, right: 0, bottom: 0, left: 20 },
width = element.parentElement.clientWidth - margin.left - margin.right,
height = +div.attr("height") - margin.top - margin.bottom;
const color = rainbowScale(_.keys(aggregates));
const stratify = d3
.stratify<Aggregate>()
.id((d) => d.account)
.parentId((d) => parentName(d.account));
const partition = hierarchy.size([width, height]).round(true);
const root = stratify(_.sortBy(aggregates, (a) => a.account))
.sum((a) => a.market_amount)
.sort(function (a, b) {
return b.height - a.height || b.value - a.value;
});
partition(root);
const cell = div
.selectAll(".node")
.data(root.descendants())
.enter()
.append("div")
.attr("class", "node")
.attr("data-tippy-content", (d) => {
return tooltip([
["Account", [d.id, "has-text-right"]],
[
"MarketAmount",
[formatCurrency(d.value), "has-text-weight-bold has-text-right"]
],
["Percentage", [percent(d), "has-text-weight-bold has-text-right"]]
]);
})
.style("top", (d: any) => d.y0 + "px")
.style("left", (d: any) => d.x0 + "px")
.style("width", (d: any) => d.x1 - d.x0 + "px")
.style("height", (d: any) => d.y1 - d.y0 + "px")
.style("background", (d) => color(d.id))
.style("color", (d) => textColor(color(d.id)));
cell
.append("p")
.attr("class", "heading has-text-weight-bold")
.text((d) => lastName(d.id));
cell
.append("p")
.attr("class", "heading has-text-weight-bold")
.style("font-size", ".5 rem")
.text(percent);
const percent = (d) => {
return formatFloat((d.value / root.value) * 100) + "%";
};
allocation("d3-allocation-category", d3.partition());
allocation("d3-allocation-value", d3.treemap());
const color = rainbowScale(_.keys(aggregates));
const stratify = d3
.stratify<Aggregate>()
.id((d) => d.account)
.parentId((d) => parentName(d.account));
const partition = hierarchy.size([width, height]).round(true);
const root = stratify(_.sortBy(aggregates, (a) => a.account))
.sum((a) => a.market_amount)
.sort(function (a, b) {
return b.height - a.height || b.value - a.value;
});
partition(root);
const cell = div
.selectAll(".node")
.data(root.descendants())
.enter()
.append("div")
.attr("class", "node")
.attr("data-tippy-content", (d) => {
return tooltip([
["Account", [d.id, "has-text-right"]],
[
"MarketAmount",
[formatCurrency(d.value), "has-text-weight-bold has-text-right"]
],
["Percentage", [percent(d), "has-text-weight-bold has-text-right"]]
]);
})
.style("top", (d: any) => d.y0 + "px")
.style("left", (d: any) => d.x0 + "px")
.style("width", (d: any) => d.x1 - d.x0 + "px")
.style("height", (d: any) => d.y1 - d.y0 + "px")
.style("background", (d) => color(d.id))
.style("color", (d) => textColor(color(d.id)));
cell
.append("p")
.attr("class", "heading has-text-weight-bold")
.text((d) => lastName(d.id));
cell
.append("p")
.attr("class", "heading has-text-weight-bold")
.style("font-size", ".5 rem")
.text(percent);
}
function renderAllocationTimeline(

View File

@ -71,10 +71,10 @@ function renderTable(gain: Gain) {
function renderOverview(gains: Gain[]) {
gains = _.sortBy(gains, (g) => g.account);
const BAR_HEIGHT = 13;
const BAR_HEIGHT = 15;
const id = "#d3-gain-overview";
const svg = d3.select(id),
margin = { top: 40, right: 40, bottom: 80, left: 150 },
margin = { top: 5, right: 20, bottom: 30, left: 150 },
width =
document.getElementById(id.substring(1)).parentElement.clientWidth -
margin.left -
@ -92,7 +92,7 @@ function renderOverview(gains: Gain[]) {
.range([0, y.bandwidth()])
.domain(["0", "1"])
.paddingInner(0)
.paddingOuter(0);
.paddingOuter(0.1);
const keys = ["balance", "investment", "withdrawal", "gain", "loss"];
const colors = ["#1f77b4", "#17becf", "#ff7f0e", "#b2df8a", "#fb9a99"];
@ -153,7 +153,7 @@ function renderOverview(gains: Gain[]) {
.text("XIRR")
.attr("text-anchor", "middle")
.attr("x", xirrWidth / 2)
.attr("y", height + 40);
.attr("y", height + 30);
g.append("g")
.attr("class", "axis y")
@ -304,6 +304,7 @@ function renderOverview(gains: Gain[]) {
.attr("height", y1.bandwidth())
.attr("width", (d) => x(d[0][1]) - x(d[0][0]));
const paddingTop = (y1.range()[1] - y1.bandwidth() * 2) / 2;
g.append("g")
.selectAll("rect")
.data(gains)
@ -311,8 +312,8 @@ function renderOverview(gains: Gain[]) {
.append("rect")
.attr("fill", (g) => (g.xirr < 0 ? z("loss") : z("gain")))
.attr("x", (g) => (g.xirr < 0 ? x1(g.xirr) : x1(0)))
.attr("y", (g) => y(restName(g.account)))
.attr("height", y.bandwidth())
.attr("y", (g) => y(restName(g.account)) + paddingTop)
.attr("height", y.bandwidth() - paddingTop * 2)
.attr("width", (g) => Math.abs(x1(0) - x1(g.xirr)));
g.append("g")

View File

@ -48,6 +48,13 @@ export interface Aggregate {
timestamp: dayjs.Dayjs;
}
export interface AllocationTarget {
name: string;
target: number;
current: number;
aggregates: { [key: string]: Aggregate };
}
export interface Income {
date: string;
postings: Posting[];
@ -77,6 +84,7 @@ export function ajax(route: "/api/gain"): Promise<{
export function ajax(route: "/api/allocation"): Promise<{
aggregates: { [key: string]: Aggregate };
aggregates_timeline: { [key: string]: Aggregate }[];
allocation_targets: AllocationTarget[];
}>;
export function ajax(route: "/api/income"): Promise<{
income_timeline: Income[];

View File

@ -179,6 +179,23 @@
</div>
</div>
</section>
<section class="section tab-allocation">
<div class="container is-fluid">
<div class="columns">
<div class="column is-12 has-text-centered">
<div id="d3-allocation-target-treemap" style="width: 100%; position: relative"></div>
<svg id="d3-allocation-target" width="100%"></svg>
</div>
</div>
<div class="columns">
<div class="column is-12 has-text-centered">
<div>
<p class="heading">Allocation Targets</p>
</div>
</div>
</div>
</div>
</section>
<section class="section tab-allocation">
<div class="container is-fluid">
<div class="columns">

View File

@ -12,7 +12,7 @@ body {
}
.inline-text text {
font-size: 10px;
font-size: 12px;
font-weight: bold;
}