This commit is contained in:
Anantha Kumaran 2022-04-23 19:01:28 +05:30
parent 46eec852ec
commit 51ee391213
8 changed files with 468 additions and 161 deletions

31
internal/server/gain.go Normal file
View File

@ -0,0 +1,31 @@
package server
import (
log "github.com/sirupsen/logrus"
"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"gorm.io/gorm"
)
type Gain struct {
Account string `json:"account"`
OverviewTimeline []Overview `json:"overview_timeline"`
}
func GetGain(db *gorm.DB) gin.H {
var postings []posting.Posting
result := db.Where("account like ?", "Asset:%").Order("date ASC").Find(&postings)
if result.Error != nil {
log.Fatal(result.Error)
}
byAccount := lo.GroupBy(postings, func(p posting.Posting) string { return p.Account })
var gains []Gain
for account, ps := range byAccount {
gains = append(gains, Gain{Account: account, OverviewTimeline: computeOverviewTimeline(db, ps)})
}
return gin.H{"gain_timeline_breakdown": gains}
}

View File

@ -12,7 +12,7 @@ import (
"gorm.io/gorm"
)
type Networth struct {
type Overview struct {
Date time.Time `json:"date"`
InvestmentAmount float64 `json:"investment_amount"`
WithdrawalAmount float64 `json:"withdrawal_amount"`
@ -25,12 +25,13 @@ func GetOverview(db *gorm.DB) gin.H {
if result.Error != nil {
log.Fatal(result.Error)
}
networthTimeline := ComputeTimeline(db, postings)
return gin.H{"networth_timeline": networthTimeline}
overviewTimeline := computeOverviewTimeline(db, postings)
return gin.H{"overview_timeline": overviewTimeline}
}
func ComputeTimeline(db *gorm.DB, postings []posting.Posting) []Networth {
var networths []Networth
func computeOverviewTimeline(db *gorm.DB, postings []posting.Posting) []Overview {
var networths []Overview
var p posting.Posting
var pastPostings []posting.Posting
@ -65,7 +66,7 @@ func ComputeTimeline(db *gorm.DB, postings []posting.Posting) []Networth {
return service.GetMarketPrice(db, p, start) - p.Amount + agg
}
}, 0)
networths = append(networths, Networth{Date: start, InvestmentAmount: investment, WithdrawalAmount: withdrawal, GainAmount: gain})
networths = append(networths, Overview{Date: start, InvestmentAmount: investment, WithdrawalAmount: withdrawal, GainAmount: gain})
}
return networths
}

View File

@ -26,6 +26,9 @@ func Listen(db *gorm.DB) {
router.GET("/api/investment", func(c *gin.Context) {
c.JSON(200, GetInvestment(db))
})
router.GET("/api/gain", func(c *gin.Context) {
c.JSON(200, GetGain(db))
})
router.GET("/api/allocation", func(c *gin.Context) {
c.JSON(200, GetAllocation(db))
})

244
web/src/gain.ts Normal file
View File

@ -0,0 +1,244 @@
import * as d3 from "d3";
import legend from "d3-svg-legend";
import dayjs from "dayjs";
import _ from "lodash";
import { ajax, formatCurrencyCrude, Overview } from "./utils";
export default async function () {
const { gain_timeline_breakdown: gains } = await ajax("/api/gain");
_.each(gains, (g) =>
_.each(g.overview_timeline, (o) => (o.timestamp = dayjs(o.date)))
);
renderLegend();
const start = _.min(
_.flatMap(gains, (g) => _.map(g.overview_timeline, (o) => o.timestamp))
),
end = dayjs();
const svgs = d3
.select("#d3-gain-timeline-breakdown")
.selectAll("svg")
.data(_.sortBy(gains, (g) => g.account));
svgs.exit().remove();
svgs
.enter()
.append("svg")
.attr("width", "100%")
.attr("height", "150")
.each(function (gain) {
renderOverviewSmall(gain.overview_timeline, this, gain.account, [
start,
end
]);
});
}
const areaKeys = ["gain", "loss"];
const colors = ["#b2df8a", "#fb9a99"];
const areaScale = d3.scaleOrdinal().domain(areaKeys).range(colors);
const lineKeys = ["networth", "investment", "withdrawal"];
const lineScale = d3
.scaleOrdinal<string>()
.domain(lineKeys)
.range(["#1f77b4", "#17becf", "#ff7f0e"]);
function renderOverviewSmall(
points: Overview[],
element: Element,
account: string,
xDomain: [dayjs.Dayjs, dayjs.Dayjs]
) {
const svg = d3.select(element),
margin = { top: 15, right: 80, bottom: 20, left: 40 },
width = element.parentElement.clientWidth - margin.left - margin.right,
height = +svg.attr("height") - margin.top - margin.bottom,
g = svg
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
const x = d3.scaleTime().range([0, width]).domain(xDomain),
y = d3
.scaleLinear()
.range([height, 0])
.domain([
0,
d3.max<Overview, number>(
points,
(d) => d.gain_amount + d.investment_amount
)
]),
z = d3.scaleOrdinal<string>(colors).domain(areaKeys);
let area = (y0, y1) =>
d3
.area<Overview>()
.curve(d3.curveBasis)
.x((d) => x(d.timestamp))
.y0(y0)
.y1(y1);
g.append("g")
.attr("class", "axis x")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x));
g.append("g")
.attr("class", "axis y")
.attr("transform", `translate(${width},0)`)
.call(
d3.axisRight(y).ticks(5).tickPadding(5).tickFormat(formatCurrencyCrude)
);
g.append("g")
.attr("class", "axis y")
.call(
d3.axisLeft(y).ticks(5).tickSize(-width).tickFormat(formatCurrencyCrude)
);
const layer = g
.selectAll(".layer")
.data([points])
.enter()
.append("g")
.attr("class", "layer");
const clipAboveID = _.uniqueId("clip-above");
layer
.append("clipPath")
.attr("id", clipAboveID)
.append("path")
.attr(
"d",
area(height, (d) => {
return y(d.gain_amount + d.investment_amount);
})
);
const clipBelowID = _.uniqueId("clip-below");
layer
.append("clipPath")
.attr("id", clipBelowID)
.append("path")
.attr(
"d",
area(0, (d) => {
return y(d.gain_amount + d.investment_amount);
})
);
layer
.append("path")
.attr(
"clip-path",
`url(${new URL("#" + clipAboveID, window.location.toString())})`
)
.style("fill", z("gain"))
.style("opacity", "0.8")
.attr(
"d",
area(0, (d) => {
return y(d.investment_amount);
})
);
layer
.append("path")
.attr(
"clip-path",
`url(${new URL("#" + clipBelowID, window.location.toString())})`
)
.style("fill", z("loss"))
.style("opacity", "0.8")
.attr(
"d",
area(height, (d) => {
return y(d.investment_amount);
})
);
layer
.append("path")
.style("stroke", lineScale("investment"))
.style("fill", "none")
.attr(
"d",
d3
.line<Overview>()
.curve(d3.curveBasis)
.x((d) => x(d.timestamp))
.y((d) => y(d.investment_amount))
);
layer
.append("path")
.style("stroke", lineScale("withdrawal"))
.style("fill", "none")
.attr(
"d",
d3
.line<Overview>()
.curve(d3.curveBasis)
.defined((d) => d.withdrawal_amount > 0)
.x((d) => x(d.timestamp))
.y((d) => y(d.withdrawal_amount))
);
layer
.append("path")
.style("stroke", lineScale("networth"))
.style("fill", "none")
.attr(
"d",
d3
.line<Overview>()
.curve(d3.curveBasis)
.x((d) => x(d.timestamp))
.y((d) => y(d.investment_amount + d.gain_amount - d.withdrawal_amount))
);
svg
.append("g")
.append("text")
.attr("transform", "translate(80,12)")
.text(account);
}
function renderLegend() {
const svg = d3.select("#d3-gain-legend");
svg
.append("g")
.attr("class", "legendOrdinal")
.attr("transform", "translate(315,3)");
const legendOrdinal = legend
.legendColor()
.shape("rect")
.orient("horizontal")
.shapePadding(50)
.labels(areaKeys)
.scale(areaScale);
svg.select(".legendOrdinal").call(legendOrdinal as any);
svg
.append("g")
.attr("class", "legendLine")
.attr("transform", "translate(30,3)");
const legendLine = legend
.legendColor()
.shape("rect")
.orient("horizontal")
.shapePadding(70)
.labelOffset(22)
.shapeHeight(3)
.shapeWidth(25)
.labels(lineKeys)
.scale(lineScale);
svg.select(".legendLine").call(legendLine as any);
}

View File

@ -11,12 +11,14 @@ import allocation from "./allocation";
import investment from "./investment";
import ledger from "./ledger";
import overview from "./overview";
import gain from "./gain";
const tabs = {
overview: _.once(overview),
investment: _.once(investment),
allocation: _.once(allocation),
ledger: _.once(ledger)
ledger: _.once(ledger),
gain: _.once(gain)
};
let tippyInstances: Instance[] = [];

View File

@ -1,5 +1,5 @@
import * as d3 from "d3";
window.d3 = d3;
import { ContainerElement } from "d3";
import legend from "d3-svg-legend";
import dayjs from "dayjs";
import _ from "lodash";
@ -7,12 +7,12 @@ import {
ajax,
formatCurrency,
formatCurrencyCrude,
Networth,
Overview,
setHtml
} from "./utils";
export default async function () {
const { networth_timeline: points } = await ajax("/api/overview");
const { overview_timeline: points } = await ajax("/api/overview");
_.each(points, (n) => (n.timestamp = dayjs(n.date)));
const current = _.last(points);
@ -28,16 +28,16 @@ export default async function () {
setHtml("withdrawal", formatCurrency(current.withdrawal_amount));
setHtml("gains", formatCurrency(current.gain_amount));
renderOverview(points, document.getElementById("d3-overview-timeline"));
}
function renderOverview(points: Overview[], element: Element) {
const start = _.min(_.map(points, (p) => p.timestamp)),
end = dayjs();
const svg = d3.select("#d3-networth-timeline"),
const svg = d3.select(element),
margin = { top: 40, right: 80, bottom: 20, left: 40 },
width =
document.getElementById("d3-networth-timeline").parentElement
.clientWidth -
margin.left -
margin.right,
width = element.parentElement.clientWidth - margin.left - margin.right,
height = +svg.attr("height") - margin.top - margin.bottom,
g = svg
.append("g")
@ -52,6 +52,141 @@ export default async function () {
.scaleOrdinal<string>()
.domain(lineKeys)
.range(["#1f77b4", "#17becf", "#ff7f0e"]);
const x = d3.scaleTime().range([0, width]).domain([start, end]),
y = d3
.scaleLinear()
.range([height, 0])
.domain([
0,
d3.max<Overview, number>(
points,
(d) => d.gain_amount + d.investment_amount
)
]),
z = d3.scaleOrdinal<string>(colors).domain(areaKeys);
let area = (y0, y1) =>
d3
.area<Overview>()
.curve(d3.curveBasis)
.x((d) => x(d.timestamp))
.y0(y0)
.y1(y1);
g.append("g")
.attr("class", "axis x")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x));
g.append("g")
.attr("class", "axis y")
.attr("transform", `translate(${width},0)`)
.call(d3.axisRight(y).tickPadding(5).tickFormat(formatCurrencyCrude));
g.append("g")
.attr("class", "axis y")
.call(d3.axisLeft(y).tickSize(-width).tickFormat(formatCurrencyCrude));
const layer = g
.selectAll(".layer")
.data([points])
.enter()
.append("g")
.attr("class", "layer");
const clipAboveID = _.uniqueId("clip-above");
layer
.append("clipPath")
.attr("id", clipAboveID)
.append("path")
.attr(
"d",
area(height, (d) => {
return y(d.gain_amount + d.investment_amount);
})
);
const clipBelowID = _.uniqueId("clip-below");
layer
.append("clipPath")
.attr("id", clipBelowID)
.append("path")
.attr(
"d",
area(0, (d) => {
return y(d.gain_amount + d.investment_amount);
})
);
layer
.append("path")
.attr(
"clip-path",
`url(${new URL("#" + clipAboveID, window.location.toString())})`
)
.style("fill", z("gain"))
.style("opacity", "0.8")
.attr(
"d",
area(0, (d) => {
return y(d.investment_amount);
})
);
layer
.append("path")
.attr(
"clip-path",
`url(${new URL("#" + clipBelowID, window.location.toString())})`
)
.style("fill", z("loss"))
.style("opacity", "0.8")
.attr(
"d",
area(height, (d) => {
return y(d.investment_amount);
})
);
layer
.append("path")
.style("stroke", lineScale("investment"))
.style("fill", "none")
.attr(
"d",
d3
.line<Overview>()
.curve(d3.curveBasis)
.x((d) => x(d.timestamp))
.y((d) => y(d.investment_amount))
);
layer
.append("path")
.style("stroke", lineScale("withdrawal"))
.style("fill", "none")
.attr(
"d",
d3
.line<Overview>()
.curve(d3.curveBasis)
.defined((d) => d.withdrawal_amount > 0)
.x((d) => x(d.timestamp))
.y((d) => y(d.withdrawal_amount))
);
layer
.append("path")
.style("stroke", lineScale("networth"))
.style("fill", "none")
.attr(
"d",
d3
.line<Overview>()
.curve(d3.curveBasis)
.x((d) => x(d.timestamp))
.y((d) => y(d.investment_amount + d.gain_amount - d.withdrawal_amount))
);
svg
.append("g")
@ -85,144 +220,4 @@ export default async function () {
.scale(lineScale);
svg.select(".legendLine").call(legendLine as any);
const x = d3.scaleTime().range([0, width]).domain([start, end]),
y = d3
.scaleLinear()
.range([height, 0])
.domain([
0,
d3.max<Networth, number>(
points,
(d) => d.gain_amount + d.investment_amount
)
]),
z = d3.scaleOrdinal<string>(colors).domain(areaKeys);
let area = (y0, y1) =>
d3
.area<Networth>()
.curve(d3.curveBasis)
.x((d) => x(d.timestamp))
.y0(y0)
.y1(y1);
g.append("g")
.attr("class", "axis x")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x));
g.append("g")
.attr("class", "axis y")
.attr("transform", `translate(${width},0)`)
.call(
d3
.axisRight(y)
.tickPadding(5)
.tickSize(10)
.tickFormat(formatCurrencyCrude)
);
g.append("g")
.attr("class", "axis y")
.call(d3.axisLeft(y).tickSize(-width).tickFormat(formatCurrencyCrude));
const layer = g
.selectAll(".layer")
.data([points])
.enter()
.append("g")
.attr("class", "layer");
layer
.append("clipPath")
.attr("id", `clip-above`)
.append("path")
.attr(
"d",
area(height, (d) => {
return y(d.gain_amount + d.investment_amount);
})
);
layer
.append("clipPath")
.attr("id", `clip-below`)
.append("path")
.attr(
"d",
area(0, (d) => {
return y(d.gain_amount + d.investment_amount);
})
);
layer
.append("path")
.attr(
"clip-path",
`url(${new URL("#clip-above", window.location.toString())})`
)
.style("fill", z("gain"))
.style("opacity", "0.8")
.attr(
"d",
area(0, (d) => {
return y(d.investment_amount);
})
);
layer
.append("path")
.attr(
"clip-path",
`url(${new URL("#clip-below", window.location.toString())})`
)
.style("fill", z("loss"))
.style("opacity", "0.8")
.attr(
"d",
area(height, (d) => {
return y(d.investment_amount);
})
);
layer
.append("path")
.style("stroke", lineScale("investment"))
.style("fill", "none")
.attr(
"d",
d3
.line<Networth>()
.curve(d3.curveBasis)
.x((d) => x(d.timestamp))
.y((d) => y(d.investment_amount))
);
layer
.append("path")
.style("stroke", lineScale("withdrawal"))
.style("fill", "none")
.attr(
"d",
d3
.line<Networth>()
.curve(d3.curveBasis)
.defined((d) => d.withdrawal_amount > 0)
.x((d) => x(d.timestamp))
.y((d) => y(d.withdrawal_amount))
);
layer
.append("path")
.style("stroke", lineScale("networth"))
.style("fill", "none")
.attr(
"d",
d3
.line<Networth>()
.curve(d3.curveBasis)
.x((d) => x(d.timestamp))
.y((d) => y(d.investment_amount + d.gain_amount - d.withdrawal_amount))
);
}

View File

@ -17,7 +17,7 @@ export interface Posting {
timestamp: dayjs.Dayjs;
}
export interface Networth {
export interface Overview {
date: string;
investment_amount: number;
withdrawal_amount: number;
@ -26,6 +26,11 @@ export interface Networth {
timestamp: dayjs.Dayjs;
}
export interface Gain {
account: string;
overview_timeline: Overview[];
}
export interface Breakdown {
group: string;
investment_amount: number;
@ -49,9 +54,13 @@ export function ajax(
export function ajax(
route: "/api/ledger"
): Promise<{ postings: Posting[]; breakdowns: Breakdown[] }>;
export function ajax(
route: "/api/overview"
): Promise<{ networth_timeline: Networth[] }>;
export function ajax(route: "/api/overview"): Promise<{
overview_timeline: Overview[];
overview_timeline_breakdown: { [key: string]: Overview[] };
}>;
export function ajax(route: "/api/gain"): Promise<{
gain_timeline_breakdown: Gain[];
}>;
export function ajax(route: "/api/allocation"): Promise<{
aggregates: { [key: string]: Aggregate };
aggregates_timeline: { [key: string]: Aggregate }[];

View File

@ -23,6 +23,7 @@
<div class="navbar-start">
<a id="overview" class="navbar-item is-active">Overview</a>
<a id="investment" class="navbar-item">Investment</a>
<a id="gain" class="navbar-item">Gain</a>
<a id="allocation" class="navbar-item">Allocation</a>
<a id="ledger" class="navbar-item">Ledger</a>
</div>
@ -62,7 +63,7 @@
<div class="container is-fluid">
<div class="columns">
<div class="column is-12">
<svg id="d3-networth-timeline" width="100%" height="500"></svg>
<svg id="d3-overview-timeline" width="100%" height="500"></svg>
</div>
</div>
<div class="columns">
@ -73,6 +74,12 @@
</div>
</div>
</div>
<div class="container is-fluid">
<div class="columns">
<div id="d3-overview-timeline-breakdown" class="column is-12">
</div>
</div>
</div>
</section>
<section class="section tab-investment">
<div class="container is-fluid">
@ -106,6 +113,21 @@
</div>
</div>
</section>
<section class="section tab-gain">
<div class="container is-fluid">
<div class="columns">
<div class="column is-12">
<svg id="d3-gain-legend" width="100%" height="50"></svg>
</div>
</div>
</div>
<div class="container is-fluid">
<div class="columns">
<div id="d3-gain-timeline-breakdown" class="column is-12">
</div>
</div>
</div>
</section>
<section class="section tab-allocation">
<div class="container is-fluid">
<div class="columns">