diff --git a/cmd/init.go b/cmd/init.go index af4f0c99..d9e2bae1 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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 diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index a3be5f10..be4b2e2a 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -10,3 +10,4 @@ # Reference Guide - [Accounts](accounts.md) +- [Allocation Targets](allocation-targets.md) diff --git a/docs/src/allocation-targets.md b/docs/src/allocation-targets.md new file mode 100644 index 00000000..6e826b18 --- /dev/null +++ b/docs/src/allocation-targets.md @@ -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:* +``` diff --git a/go.mod b/go.mod index 531e3093..8cb8f0ae 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 178f7319..ab53dc1c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/server/allocation.go b/internal/server/allocation.go index cd06c642..904ae1d3 100644 --- a/internal/server/allocation.go +++ b/internal/server/allocation.go @@ -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) diff --git a/web/src/allocation.ts b/web/src/allocation.ts index 4cf29a2f..38c7827f 100644 --- a/web/src/allocation.ts +++ b/web/src/allocation.ts @@ -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(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; - - const percent = (d) => { - return formatFloat((d.value / root.value) * 100) + "%"; - }; - - const color = rainbowScale(_.keys(aggregates)); - - const stratify = d3 - .stratify() - .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); + renderPartition( + document.getElementById("d3-allocation-category"), + aggregates, + d3.partition() + ); + renderPartition( + document.getElementById("d3-allocation-value"), + aggregates, + d3.treemap() + ); +} + +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 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() + .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( diff --git a/web/src/gain.ts b/web/src/gain.ts index d72d22eb..d6ceefb1 100644 --- a/web/src/gain.ts +++ b/web/src/gain.ts @@ -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") diff --git a/web/src/utils.ts b/web/src/utils.ts index 771215d4..e2f4de6b 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -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[]; diff --git a/web/static/index.html b/web/static/index.html index 4b9ca5a9..ad778d33 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -179,6 +179,23 @@ +
+
+
+
+
+ +
+
+
+
+
+

Allocation Targets

+
+
+
+
+
diff --git a/web/static/styles/custom.css b/web/static/styles/custom.css index d9796b20..fe9b8918 100644 --- a/web/static/styles/custom.css +++ b/web/static/styles/custom.css @@ -12,7 +12,7 @@ body { } .inline-text text { - font-size: 10px; + font-size: 12px; font-weight: bold; }