add support for NPS fund price tracking

This commit is contained in:
Anantha Kumaran 2022-08-07 14:22:08 +05:30
parent 4a23646076
commit a63386ea77
15 changed files with 345 additions and 33 deletions

87
cmd/nps.go Normal file
View File

@ -0,0 +1,87 @@
package cmd
import (
"github.com/ananthakumaran/paisa/internal/model/nps/scheme"
"github.com/ananthakumaran/paisa/internal/scraper/nps"
"github.com/logrusorgru/aurora"
"github.com/manifoldco/promptui"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"strings"
)
var npsUpdate bool
var npsCmd = &cobra.Command{
Use: "nps",
Short: "Search nps fund",
Run: func(cmd *cobra.Command, args []string) {
db, err := gorm.Open(sqlite.Open(viper.GetString("db_path")), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&scheme.Scheme{})
count := scheme.Count(db)
if update || count == 0 {
schemes, err := nps.GetSchemes()
if err != nil {
log.Fatal(err)
}
scheme.UpsertAll(db, schemes)
} else {
log.Info("Using cached results; pass '-u' to update the cache")
}
pfm := promptPFM(db)
name := npsPromptName(db, pfm)
scheme := scheme.FindScheme(db, pfm, name)
log.Info("NPS Fund Scheme Code: ", aurora.Bold(scheme.SchemeID))
},
}
func init() {
searchCmd.AddCommand(npsCmd)
npsCmd.Flags().BoolVarP(&update, "update", "u", false, "update the NPS Fund Scheme list")
}
func promptPFM(db *gorm.DB) string {
pfms := scheme.GetPFMs(db)
return npsPrompt("Pension Fund Manager", pfms)
}
func npsPromptName(db *gorm.DB, pfm string) string {
names := scheme.GetSchemeNames(db, pfm)
return npsPrompt("Fund Name", names)
}
func npsPrompt(label string, list []string) string {
searcher := func(input string, index int) bool {
item := list[index]
item = strings.Replace(strings.ToLower(item), " ", "", -1)
words := strings.Split(strings.ToLower(input), " ")
for _, word := range words {
if strings.TrimSpace(word) != "" && !strings.Contains(item, word) {
return false
}
}
return true
}
prompt := promptui.Select{
Label: label,
Items: list,
Size: 10,
Searcher: searcher,
StartInSearchMode: true,
}
_, item, err := prompt.Run()
if err != nil {
log.Fatal(err)
}
return item
}

View File

@ -1,6 +1,10 @@
package cmd package cmd
import ( import (
mutualfund "github.com/ananthakumaran/paisa/internal/model/mutualfund/scheme"
nps "github.com/ananthakumaran/paisa/internal/model/nps/scheme"
"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/model/price"
"github.com/ananthakumaran/paisa/internal/server" "github.com/ananthakumaran/paisa/internal/server"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -14,6 +18,11 @@ var serveCmd = &cobra.Command{
Short: "serve the WEB UI", Short: "serve the WEB UI",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
db, err := gorm.Open(sqlite.Open(viper.GetString("db_path")), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(viper.GetString("db_path")), &gorm.Config{})
db.AutoMigrate(&nps.Scheme{})
db.AutoMigrate(&mutualfund.Scheme{})
db.AutoMigrate(&posting.Posting{})
db.AutoMigrate(&price.Price{})
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -20,22 +20,22 @@ tracked as a commodity. Few example transactions can be found below.
Checking Checking
``` ```
**paisa** comes with inbuilt support for fetching the latest price of some **paisa** comes with inbuilt support for fetching the latest price of
commodities like mutual fund. For others, it will try to use the some commodities like mutual fund and NPS. For others, it will try to
latest purchase price specified in the journal. For example, when you use the latest purchase price specified in the journal. For example,
enter the second NPS transaction on `2019/02/21`, the valuation of when you enter the second NPS transaction on `2019/02/21`, the
your existing holdings will be adjusted based on the new purchase valuation of your existing holdings will be adjusted based on the new
price. purchase price.
## Mutual Fund ## Mutual Fund
To automatically track the latest value of your mutual fund holdings, To automatically track the latest value of your mutual funds holdings,
you need to link the commodity and the fund scheme code. you need to link the commodity and the fund scheme code.
```yaml ```yaml
commodities: commodities:
- name: NIFTY # commodity name - name: NIFTY # commodity name
type: mutualfund # type (only mutualfund supported as of now) type: mutualfund # type
code: 120716 # mutual fund scheme code code: 120716 # mutual fund scheme code
- name: NIFTY_JR - name: NIFTY_JR
type: mutualfund type: mutualfund
@ -55,6 +55,31 @@ INFO Using cached results; pass '-u' to update the cache
INFO Mutual Fund Scheme Code: 120684 INFO Mutual Fund Scheme Code: 120684
``` ```
## NPS
To automatically track the latest value of your nps funds holdings,
you need to link the commodity and the fund scheme code.
```yaml
commodities:
- name: NPS_HDFC_E # commodity name
type: nps # type
code: SM008002 # nps fund scheme code
```
The example config above links NPS fund commodity with their
respective NPS fund scheme code. The scheme code can be found using
the *search* command.
```
paisa search nps
INFO Using config file: /home/john/finance/paisa.yaml
INFO Using cached results; pass '-u' to update the cache
✔ HDFC Pension Management Company Limited
✔ HDFC PENSION MANAGEMENT COMPANY LIMITED SCHEME C - TIER I
INFO NPS Fund Scheme Code: SM008002
```
## Updates ## Updates
**paisa** fetches the latest price of the commodities only when **paisa** fetches the latest price of the commodities only when

View File

@ -17,7 +17,7 @@ INFO Generating journal file: /home/john/finance/personal.ledger
paisa update paisa update
INFO Using config file: /home/john/finance/paisa.yaml INFO Using config file: /home/john/finance/paisa.yaml
INFO Syncing transactions from journal INFO Syncing transactions from journal
INFO Fetching mutual fund history INFO Fetching commodities price history
INFO Fetching commodity NIFTY INFO Fetching commodity NIFTY
INFO Fetching commodity NIFTY_JR INFO Fetching commodity NIFTY_JR
paisa serve paisa serve

View File

@ -4,6 +4,7 @@
finance, specially tailored for Indians. It builds on top of the finance, specially tailored for Indians. It builds on top of the
[ledger](https://www.ledger-cli.org/) double entry accounting tool. [ledger](https://www.ledger-cli.org/) double entry accounting tool.
* Your financial data never leaves your system * Your financial data never leaves your system.
* The journal and config information are stored in plain text files * The journal and config information are stored in plain text files
that can be easily version controlled that can be easily version controlled.
* Can track the latest market price of Mutual Funds and NPS Funds holdings.

View File

@ -13,13 +13,13 @@ content
```go ```go
2022/01/01 Salary 2022/01/01 Salary
Income:Salary -100,000 INR Income:Salary:Acme -100,000 INR
Checking 100,000 INR Checking 100,000 INR
``` ```
**ledger** follows the double-entry accounting system. In simple terms, it **ledger** follows the double-entry accounting system. In simple terms, it
tracks the movement of money from debit account to credit tracks the movement of money from debit account to credit
account. Here `Income:Salary` is the debit account and account. Here `Income:Salary:Acme` is the debit account and
`Checking` is the credit account. The date at which the `Checking` is the credit account. The date at which the
transaction took place and a description of the transaction is written transaction took place and a description of the transaction is written
in the first line followed by the list of credit or debit in the first line followed by the list of credit or debit
@ -30,15 +30,15 @@ accounts must be zero.
```go ```go
2022/01/01 Salary 2022/01/01 Salary
Income:Salary -100,000 INR Income:Salary:Acme -100,000 INR
Checking 100,000 INR Checking 100,000 INR
2022/02/01 Salary 2022/02/01 Salary
Income:Salary -100,000 INR Income:Salary:Acme -100,000 INR
Checking 100,000 INR Checking 100,000 INR
2022/03/01 Salary 2022/03/01 Salary
Income:Salary -100,000 INR Income:Salary:Acme -100,000 INR
Checking 100,000 INR Checking 100,000 INR
``` ```
@ -55,17 +55,17 @@ we could represent it as follows
```go ```go
2022/01/01 Salary 2022/01/01 Salary
Income:Salary -100,000 INR Income:Salary:Acme -100,000 INR
Checking 88,000 INR Checking 88,000 INR
Asset:Debt:EPF 12,000 INR Asset:Debt:EPF 12,000 INR
2022/02/01 Salary 2022/02/01 Salary
Income:Salary -100,000 INR Income:Salary:Acme -100,000 INR
Checking 88,000 INR Checking 88,000 INR
Asset:Debt:EPF 12,000 INR Asset:Debt:EPF 12,000 INR
2022/03/01 Salary 2022/03/01 Salary
Income:Salary -100,000 INR Income:Salary:Acme -100,000 INR
Checking 88,000 INR Checking 88,000 INR
Asset:Debt:EPF 12,000 INR Asset:Debt:EPF 12,000 INR
``` ```
@ -144,10 +144,10 @@ commodities:
code: 120684 code: 120684
``` ```
**paisa** can fetch the latest price of mutual fund commodity as of **paisa** can fetch the latest price of mutual funds and nps funds as
today. For other types of commodities, the purchase/sell price of the of today. For other types of commodities, the purchase/sell price of
last transaction would be considered the latest price. The code is the the last transaction would be considered the latest price. The code is
scheme code of the fund. The *search* command can be used to find the scheme code of the fund. The *search* command can be used to find
scheme code scheme code
```shell ```shell
@ -173,7 +173,7 @@ the serve command.
paisa update paisa update
INFO Using config file: /home/john/finance/paisa.yaml INFO Using config file: /home/john/finance/paisa.yaml
INFO Syncing transactions from journal INFO Syncing transactions from journal
INFO Fetching mutual fund history INFO Fetching commodities price history
INFO Fetching commodity NIFTY INFO Fetching commodity NIFTY
INFO Fetching commodity NIFTY_JR INFO Fetching commodity NIFTY_JR
paisa serve paisa serve

View File

@ -1,12 +1,11 @@
package model package model
import ( import (
"strconv"
"github.com/ananthakumaran/paisa/internal/ledger" "github.com/ananthakumaran/paisa/internal/ledger"
"github.com/ananthakumaran/paisa/internal/model/posting" "github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/model/price" "github.com/ananthakumaran/paisa/internal/model/price"
"github.com/ananthakumaran/paisa/internal/scraper/mutualfund" "github.com/ananthakumaran/paisa/internal/scraper/mutualfund"
"github.com/ananthakumaran/paisa/internal/scraper/nps"
"github.com/logrusorgru/aurora" "github.com/logrusorgru/aurora"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/viper" "github.com/spf13/viper"
@ -20,11 +19,11 @@ func Sync(db *gorm.DB) {
posting.UpsertAll(db, postings) posting.UpsertAll(db, postings)
db.AutoMigrate(&price.Price{}) db.AutoMigrate(&price.Price{})
log.Info("Fetching mutual fund history") log.Info("Fetching commodities price history")
type Commodity struct { type Commodity struct {
Name string Name string
Type string Type string
Code int Code string
} }
var commodities []Commodity var commodities []Commodity
@ -32,8 +31,17 @@ func Sync(db *gorm.DB) {
for _, commodity := range commodities { for _, commodity := range commodities {
name := commodity.Name name := commodity.Name
log.Info("Fetching commodity ", aurora.Bold(name)) log.Info("Fetching commodity ", aurora.Bold(name))
schemeCode := strconv.Itoa(commodity.Code) schemeCode := commodity.Code
prices, err := mutualfund.GetNav(schemeCode, name) var prices []*price.Price
var err error
switch commodity.Type {
case string(price.MutualFund):
prices, err = mutualfund.GetNav(schemeCode, name)
case string(price.NPS):
prices, err = nps.GetNav(schemeCode, name)
}
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -0,0 +1,65 @@
package scheme
import (
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type Scheme struct {
ID uint `gorm:"primaryKey" json:"id"`
PFMName string
SchemeID string
SchemeName string
}
func (Scheme) TableName() string {
return "nps_schemes"
}
func Count(db *gorm.DB) int64 {
var count int64
db.Model(&Scheme{}).Count(&count)
return count
}
func UpsertAll(db *gorm.DB, schemes []*Scheme) {
err := db.Transaction(func(tx *gorm.DB) error {
err := tx.Exec("DELETE FROM nps_schemes").Error
if err != nil {
return err
}
for _, scheme := range schemes {
err := tx.Create(scheme).Error
if err != nil {
return err
}
}
return nil
})
if err != nil {
log.Fatal(err)
}
}
func GetPFMs(db *gorm.DB) []string {
var pfms []string
db.Model(&Scheme{}).Distinct().Pluck("PFMName", &pfms)
return pfms
}
func GetSchemeNames(db *gorm.DB, pfm string) []string {
var schemeNames []string
db.Model(&Scheme{}).Where("pfm_name = ?", pfm).Pluck("SchemeName", &schemeNames)
return schemeNames
}
func FindScheme(db *gorm.DB, pfm string, schemeName string) Scheme {
var scheme Scheme
result := db.Where("pfm_name = ? and scheme_name = ?", pfm, schemeName).First(&scheme)
if result.Error != nil {
log.Fatal(result)
}
return scheme
}

View File

@ -13,6 +13,7 @@ type CommodityType string
const ( const (
MutualFund CommodityType = "mutualfund" MutualFund CommodityType = "mutualfund"
NPS CommodityType = "nps"
) )
type Price struct { type Price struct {

View File

@ -0,0 +1,52 @@
package nps
import (
"encoding/json"
"fmt"
"io/ioutil"
"time"
"net/http"
"github.com/ananthakumaran/paisa/internal/model/price"
)
func GetNav(schemeCode string, commodityName string) ([]*price.Price, error) {
url := fmt.Sprintf("https://nps.purifiedbytes.com/api/schemes/%s/nav.json", schemeCode)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
type Data struct {
Date string
Nav float64
}
type Result struct {
Data []Data
}
var result Result
err = json.Unmarshal(respBytes, &result)
if err != nil {
return nil, err
}
var prices []*price.Price
for _, data := range result.Data {
date, err := time.Parse("2006-01-02", data.Date)
if err != nil {
return nil, err
}
price := price.Price{Date: date, CommodityType: price.NPS, CommodityID: schemeCode, CommodityName: commodityName, Value: data.Nav}
prices = append(prices, &price)
}
return prices, nil
}

View File

@ -0,0 +1,47 @@
package nps
import (
"net/http"
"encoding/json"
"github.com/ananthakumaran/paisa/internal/model/nps/scheme"
log "github.com/sirupsen/logrus"
"io/ioutil"
)
func GetSchemes() ([]*scheme.Scheme, error) {
log.Info("Fetching NPS scheme list from Purified Bytes")
resp, err := http.Get("https://nps.purifiedbytes.com/api/schemes.json")
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
type Scheme struct {
Id string
Name string
PFMName string `json:"pfm_name"`
}
type Result struct {
Data []Scheme
}
var result Result
err = json.Unmarshal(respBytes, &result)
if err != nil {
return nil, err
}
var schemes []*scheme.Scheme
for _, s := range result.Data {
scheme := scheme.Scheme{PFMName: s.PFMName, SchemeID: s.Id, SchemeName: s.Name}
schemes = append(schemes, &scheme)
}
return schemes, nil
}

View File

@ -17,6 +17,8 @@ type Breakdown struct {
InvestmentAmount float64 `json:"investment_amount"` InvestmentAmount float64 `json:"investment_amount"`
WithdrawalAmount float64 `json:"withdrawal_amount"` WithdrawalAmount float64 `json:"withdrawal_amount"`
MarketAmount float64 `json:"market_amount"` MarketAmount float64 `json:"market_amount"`
BalanceUnits float64 `json:"balance_units"`
LatestPrice float64 `json:"latest_price"`
XIRR float64 `json:"xirr"` XIRR float64 `json:"xirr"`
} }
@ -38,14 +40,15 @@ func computeBreakdown(db *gorm.DB, postings []posting.Posting) map[string]Breakd
var parts []string var parts []string
for _, part := range strings.Split(p.Account, ":") { for _, part := range strings.Split(p.Account, ":") {
parts = append(parts, part) parts = append(parts, part)
accounts[strings.Join(parts, ":")] = true accounts[strings.Join(parts, ":")] = false
} }
accounts[p.Account] = true
} }
result := make(map[string]Breakdown) result := make(map[string]Breakdown)
for group := range accounts { for group, leaf := range accounts {
ps := lo.Filter(postings, func(p posting.Posting, _ int) bool { return strings.HasPrefix(p.Account, group) }) ps := lo.Filter(postings, func(p posting.Posting, _ int) bool { return strings.HasPrefix(p.Account, group) })
investmentAmount := lo.Reduce(ps, func(acc float64, p posting.Posting, _ int) float64 { investmentAmount := lo.Reduce(ps, func(acc float64, p posting.Posting, _ int) float64 {
if p.Amount < 0 || service.IsInterest(db, p) { if p.Amount < 0 || service.IsInterest(db, p) {
@ -62,9 +65,18 @@ func computeBreakdown(db *gorm.DB, postings []posting.Posting) map[string]Breakd
} }
}, 0.0) }, 0.0)
marketAmount := lo.Reduce(ps, func(acc float64, p posting.Posting, _ int) float64 { return acc + p.MarketAmount }, 0.0) marketAmount := lo.Reduce(ps, func(acc float64, p posting.Posting, _ int) float64 { return acc + p.MarketAmount }, 0.0)
var balanceUnits float64
if leaf {
balanceUnits = lo.Reduce(ps, func(acc float64, p posting.Posting, _ int) float64 {
if p.Commodity != "INR" {
return acc + p.Quantity
}
return 0.0
}, 0.0)
}
xirr := service.XIRR(db, ps) xirr := service.XIRR(db, ps)
breakdown := Breakdown{InvestmentAmount: investmentAmount, WithdrawalAmount: withdrawalAmount, MarketAmount: marketAmount, XIRR: xirr, Group: group} breakdown := Breakdown{InvestmentAmount: investmentAmount, WithdrawalAmount: withdrawalAmount, MarketAmount: marketAmount, XIRR: xirr, Group: group, BalanceUnits: balanceUnits}
result[group] = breakdown result[group] = breakdown
} }

View File

@ -107,6 +107,9 @@ function renderBreakdowns(breakdowns: Breakdown[]) {
)}</td> )}</td>
<td class='has-text-right'>${formatCurrency(b.investment_amount)}</td> <td class='has-text-right'>${formatCurrency(b.investment_amount)}</td>
<td class='has-text-right'>${formatCurrency(b.withdrawal_amount)}</td> <td class='has-text-right'>${formatCurrency(b.withdrawal_amount)}</td>
<td class='has-text-right'>${
b.balance_units > 0 ? formatFloat(b.balance_units, 4) : ""
}</td>
<td class='has-text-right'>${formatCurrency(b.market_amount)}</td> <td class='has-text-right'>${formatCurrency(b.market_amount)}</td>
<td class='${changeClass} has-text-right'>${formatCurrency(gain)}</td> <td class='${changeClass} has-text-right'>${formatCurrency(gain)}</td>
<td class='${changeClass} has-text-right'>${formatFloat(b.xirr)}</td> <td class='${changeClass} has-text-right'>${formatFloat(b.xirr)}</td>

View File

@ -35,6 +35,7 @@ export interface Breakdown {
group: string; group: string;
investment_amount: number; investment_amount: number;
withdrawal_amount: number; withdrawal_amount: number;
balance_units: number;
market_amount: number; market_amount: number;
xirr: number; xirr: number;
} }

View File

@ -286,6 +286,7 @@
<th>Account</th> <th>Account</th>
<th class='has-text-right'>Investment Amount</th> <th class='has-text-right'>Investment Amount</th>
<th class='has-text-right'>Withdrawal Amount</th> <th class='has-text-right'>Withdrawal Amount</th>
<th class='has-text-right'>Balance Units</th>
<th class='has-text-right'>Market Value</th> <th class='has-text-right'>Market Value</th>
<th class='has-text-right'>Change</th> <th class='has-text-right'>Change</th>
<th class='has-text-right'>XIRR</th> <th class='has-text-right'>XIRR</th>