add support for NPS fund price tracking
This commit is contained in:
parent
4a23646076
commit
a63386ea77
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -13,6 +13,7 @@ type CommodityType string
|
|||
|
||||
const (
|
||||
MutualFund CommodityType = "mutualfund"
|
||||
NPS CommodityType = "nps"
|
||||
)
|
||||
|
||||
type Price struct {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -35,6 +35,7 @@ export interface Breakdown {
|
|||
group: string;
|
||||
investment_amount: number;
|
||||
withdrawal_amount: number;
|
||||
balance_units: number;
|
||||
market_amount: number;
|
||||
xirr: number;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue