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
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"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
@ -14,6 +18,11 @@ var serveCmd = &cobra.Command{
Short: "serve the WEB UI",
Run: func(cmd *cobra.Command, args []string) {
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 {
log.Fatal(err)
}

View File

@ -20,22 +20,22 @@ tracked as a commodity. Few example transactions can be found below.
Checking
```
**paisa** comes with inbuilt support for fetching the latest price of some
commodities like mutual fund. For others, it will try to use the
latest purchase price specified in the journal. For example, when you
enter the second NPS transaction on `2019/02/21`, the valuation of
your existing holdings will be adjusted based on the new purchase
price.
**paisa** comes with inbuilt support for fetching the latest price of
some commodities like mutual fund and NPS. For others, it will try to
use the latest purchase price specified in the journal. For example,
when you enter the second NPS transaction on `2019/02/21`, the
valuation of your existing holdings will be adjusted based on the new
purchase price.
## 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.
```yaml
commodities:
- name: NIFTY # commodity name
type: mutualfund # type (only mutualfund supported as of now)
type: mutualfund # type
code: 120716 # mutual fund scheme code
- name: NIFTY_JR
type: mutualfund
@ -55,6 +55,31 @@ INFO Using cached results; pass '-u' to update the cache
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
**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
INFO Using config file: /home/john/finance/paisa.yaml
INFO Syncing transactions from journal
INFO Fetching mutual fund history
INFO Fetching commodities price history
INFO Fetching commodity NIFTY
INFO Fetching commodity NIFTY_JR
paisa serve

View File

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

View File

@ -1,12 +1,11 @@
package model
import (
"strconv"
"github.com/ananthakumaran/paisa/internal/ledger"
"github.com/ananthakumaran/paisa/internal/model/posting"
"github.com/ananthakumaran/paisa/internal/model/price"
"github.com/ananthakumaran/paisa/internal/scraper/mutualfund"
"github.com/ananthakumaran/paisa/internal/scraper/nps"
"github.com/logrusorgru/aurora"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
@ -20,11 +19,11 @@ func Sync(db *gorm.DB) {
posting.UpsertAll(db, postings)
db.AutoMigrate(&price.Price{})
log.Info("Fetching mutual fund history")
log.Info("Fetching commodities price history")
type Commodity struct {
Name string
Type string
Code int
Code string
}
var commodities []Commodity
@ -32,8 +31,17 @@ func Sync(db *gorm.DB) {
for _, commodity := range commodities {
name := commodity.Name
log.Info("Fetching commodity ", aurora.Bold(name))
schemeCode := strconv.Itoa(commodity.Code)
prices, err := mutualfund.GetNav(schemeCode, name)
schemeCode := commodity.Code
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 {
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 (
MutualFund CommodityType = "mutualfund"
NPS CommodityType = "nps"
)
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"`
WithdrawalAmount float64 `json:"withdrawal_amount"`
MarketAmount float64 `json:"market_amount"`
BalanceUnits float64 `json:"balance_units"`
LatestPrice float64 `json:"latest_price"`
XIRR float64 `json:"xirr"`
}
@ -38,14 +40,15 @@ func computeBreakdown(db *gorm.DB, postings []posting.Posting) map[string]Breakd
var parts []string
for _, part := range strings.Split(p.Account, ":") {
parts = append(parts, part)
accounts[strings.Join(parts, ":")] = true
accounts[strings.Join(parts, ":")] = false
}
accounts[p.Account] = true
}
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) })
investmentAmount := lo.Reduce(ps, func(acc float64, p posting.Posting, _ int) float64 {
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)
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)
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
}

View File

@ -107,6 +107,9 @@ function renderBreakdowns(breakdowns: Breakdown[]) {
)}</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'>${
b.balance_units > 0 ? formatFloat(b.balance_units, 4) : ""
}</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'>${formatFloat(b.xirr)}</td>

View File

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

View File

@ -286,6 +286,7 @@
<th>Account</th>
<th class='has-text-right'>Investment 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'>Change</th>
<th class='has-text-right'>XIRR</th>