mirror of
https://github.com/aclindsa/moneygo.git
synced 2024-12-25 23:23:21 -05:00
Add security prices
* Import them from Gnucash's pricedb * Add support for querying prices from lua for reports * Add documentation for lua reports
This commit is contained in:
parent
594555b0c4
commit
f213e1061c
1
db.go
1
db.go
@ -22,6 +22,7 @@ func initDB() *gorp.DbMap {
|
|||||||
dbmap.AddTableWithName(Security{}, "securities").SetKeys(true, "SecurityId")
|
dbmap.AddTableWithName(Security{}, "securities").SetKeys(true, "SecurityId")
|
||||||
dbmap.AddTableWithName(Transaction{}, "transactions").SetKeys(true, "TransactionId")
|
dbmap.AddTableWithName(Transaction{}, "transactions").SetKeys(true, "TransactionId")
|
||||||
dbmap.AddTableWithName(Split{}, "splits").SetKeys(true, "SplitId")
|
dbmap.AddTableWithName(Split{}, "splits").SetKeys(true, "SplitId")
|
||||||
|
dbmap.AddTableWithName(Price{}, "prices").SetKeys(true, "PriceId")
|
||||||
dbmap.AddTableWithName(Report{}, "reports").SetKeys(true, "ReportId")
|
dbmap.AddTableWithName(Report{}, "reports").SetKeys(true, "ReportId")
|
||||||
|
|
||||||
err = dbmap.CreateTablesIfNotExists()
|
err = dbmap.CreateTablesIfNotExists()
|
||||||
|
@ -136,9 +136,37 @@ has several fields describing it:
|
|||||||
* `s.Type` returns an int constant which represents what type of security it is
|
* `s.Type` returns an int constant which represents what type of security it is
|
||||||
(i.e. stock or currency)
|
(i.e. stock or currency)
|
||||||
|
|
||||||
|
Securities support a ClosestPrice function that allows you to fetch the price of
|
||||||
|
the current security in a given currency that is closest to the supplied date.
|
||||||
|
For example, to print the price in the user's default currency for each security
|
||||||
|
in the user's account:
|
||||||
|
|
||||||
|
```
|
||||||
|
default_currency = get_default_currency()
|
||||||
|
for id, security in pairs(get_securities()) do
|
||||||
|
price = security.price(default_currency, date.now())
|
||||||
|
if price ~= nil then
|
||||||
|
print(tostring(security) .. ": " security.Symbol .. " " .. price.Value)
|
||||||
|
else
|
||||||
|
print("Failed to fetch price for " .. tostring(security))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
You can also query for an account's default currency using the global
|
You can also query for an account's default currency using the global
|
||||||
`get_default_currency()` function.
|
`get_default_currency()` function.
|
||||||
|
|
||||||
|
### Prices
|
||||||
|
|
||||||
|
Price objects can be queried from Security objects. Price objects contain the
|
||||||
|
following fields:
|
||||||
|
|
||||||
|
* `p.PriceId`
|
||||||
|
* `p.Security` returns the security object the price is for
|
||||||
|
* `p.Currency` returns the currency that the price is in
|
||||||
|
* `p.Value` returns the price of one unit of 'security' in 'currency', as a
|
||||||
|
float
|
||||||
|
|
||||||
### Dates
|
### Dates
|
||||||
|
|
||||||
In order to make it easier to do operations like finding account balances for a
|
In order to make it easier to do operations like finding account balances for a
|
||||||
|
63
gnucash.go
63
gnucash.go
@ -72,6 +72,20 @@ type GnucashDate struct {
|
|||||||
Date GnucashTime `xml:"http://www.gnucash.org/XML/ts date"`
|
Date GnucashTime `xml:"http://www.gnucash.org/XML/ts date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GnucashPrice struct {
|
||||||
|
Id string `xml:"http://www.gnucash.org/XML/price id"`
|
||||||
|
Commodity GnucashCommodity `xml:"http://www.gnucash.org/XML/price commodity"`
|
||||||
|
Currency GnucashCommodity `xml:"http://www.gnucash.org/XML/price currency"`
|
||||||
|
Date GnucashDate `xml:"http://www.gnucash.org/XML/price time"`
|
||||||
|
Source string `xml:"http://www.gnucash.org/XML/price source"`
|
||||||
|
Type string `xml:"http://www.gnucash.org/XML/price type"`
|
||||||
|
Value string `xml:"http://www.gnucash.org/XML/price value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GnucashPriceDB struct {
|
||||||
|
Prices []GnucashPrice `xml:"price"`
|
||||||
|
}
|
||||||
|
|
||||||
type GnucashAccount struct {
|
type GnucashAccount struct {
|
||||||
Version string `xml:"version,attr"`
|
Version string `xml:"version,attr"`
|
||||||
accountid int64 // Used to map Gnucash guid's to integer ones
|
accountid int64 // Used to map Gnucash guid's to integer ones
|
||||||
@ -105,6 +119,7 @@ type GnucashSplit struct {
|
|||||||
type GnucashXMLImport struct {
|
type GnucashXMLImport struct {
|
||||||
XMLName xml.Name `xml:"gnc-v2"`
|
XMLName xml.Name `xml:"gnc-v2"`
|
||||||
Commodities []GnucashCommodity `xml:"http://www.gnucash.org/XML/gnc book>commodity"`
|
Commodities []GnucashCommodity `xml:"http://www.gnucash.org/XML/gnc book>commodity"`
|
||||||
|
PriceDB GnucashPriceDB `xml:"http://www.gnucash.org/XML/gnc book>pricedb"`
|
||||||
Accounts []GnucashAccount `xml:"http://www.gnucash.org/XML/gnc book>account"`
|
Accounts []GnucashAccount `xml:"http://www.gnucash.org/XML/gnc book>account"`
|
||||||
Transactions []GnucashTransaction `xml:"http://www.gnucash.org/XML/gnc book>transaction"`
|
Transactions []GnucashTransaction `xml:"http://www.gnucash.org/XML/gnc book>transaction"`
|
||||||
}
|
}
|
||||||
@ -113,6 +128,7 @@ type GnucashImport struct {
|
|||||||
Securities []Security
|
Securities []Security
|
||||||
Accounts []Account
|
Accounts []Account
|
||||||
Transactions []Transaction
|
Transactions []Transaction
|
||||||
|
Prices []Price
|
||||||
}
|
}
|
||||||
|
|
||||||
func ImportGnucash(r io.Reader) (*GnucashImport, error) {
|
func ImportGnucash(r io.Reader) (*GnucashImport, error) {
|
||||||
@ -141,6 +157,38 @@ func ImportGnucash(r io.Reader) (*GnucashImport, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create prices, setting security and currency IDs from securityMap
|
||||||
|
for i := range gncxml.PriceDB.Prices {
|
||||||
|
price := gncxml.PriceDB.Prices[i]
|
||||||
|
var p Price
|
||||||
|
security, ok := securityMap[price.Commodity.Name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("Unable to find commodity '%s' for price '%s'", price.Commodity.Name, price.Id)
|
||||||
|
}
|
||||||
|
currency, ok := securityMap[price.Currency.Name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("Unable to find currency '%s' for price '%s'", price.Currency.Name, price.Id)
|
||||||
|
}
|
||||||
|
if currency.Type != Currency {
|
||||||
|
return nil, fmt.Errorf("Currency for imported price isn't actually a currency\n")
|
||||||
|
}
|
||||||
|
p.PriceId = int64(i + 1)
|
||||||
|
p.SecurityId = security.SecurityId
|
||||||
|
p.CurrencyId = currency.SecurityId
|
||||||
|
p.Date = price.Date.Date.Time
|
||||||
|
|
||||||
|
var r big.Rat
|
||||||
|
_, ok = r.SetString(price.Value)
|
||||||
|
if ok {
|
||||||
|
p.Value = r.FloatString(currency.Precision)
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("Can't set price value: %s", price.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.RemoteId = "gnucash:" + price.Id
|
||||||
|
gncimport.Prices = append(gncimport.Prices, p)
|
||||||
|
}
|
||||||
|
|
||||||
//find root account, while simultaneously creating map of GUID's to
|
//find root account, while simultaneously creating map of GUID's to
|
||||||
//accounts
|
//accounts
|
||||||
var rootAccount GnucashAccount
|
var rootAccount GnucashAccount
|
||||||
@ -340,6 +388,21 @@ func GnucashImportHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
securityMap[securityId] = s.SecurityId
|
securityMap[securityId] = s.SecurityId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import prices, setting security and currency IDs from securityMap
|
||||||
|
for _, price := range gnucashImport.Prices {
|
||||||
|
price.SecurityId = securityMap[price.SecurityId]
|
||||||
|
price.CurrencyId = securityMap[price.CurrencyId]
|
||||||
|
price.PriceId = 0
|
||||||
|
|
||||||
|
err := CreatePriceIfNotExist(sqltransaction, &price)
|
||||||
|
if err != nil {
|
||||||
|
sqltransaction.Rollback()
|
||||||
|
WriteError(w, 6 /*Import Error*/)
|
||||||
|
log.Print(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get/create accounts in the database, building a map from Gnucash account
|
// Get/create accounts in the database, building a map from Gnucash account
|
||||||
// IDs to our internal IDs as we go
|
// IDs to our internal IDs as we go
|
||||||
accountMap := make(map[int64]int64)
|
accountMap := make(map[int64]int64)
|
||||||
|
122
prices.go
Normal file
122
prices.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/FlashBoys/go-finance"
|
||||||
|
"gopkg.in/gorp.v1"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Price struct {
|
||||||
|
PriceId int64
|
||||||
|
SecurityId int64
|
||||||
|
CurrencyId int64
|
||||||
|
Date time.Time
|
||||||
|
Value string // String representation of decimal price of Security in Currency units, suitable for passing to big.Rat.SetString()
|
||||||
|
RemoteId string // unique ID from source, for detecting duplicates
|
||||||
|
}
|
||||||
|
|
||||||
|
func InsertPriceTx(transaction *gorp.Transaction, p *Price) error {
|
||||||
|
err := transaction.Insert(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreatePriceIfNotExist(transaction *gorp.Transaction, price *Price) error {
|
||||||
|
if len(price.RemoteId) == 0 {
|
||||||
|
// Always create a new price if we can't match on the RemoteId
|
||||||
|
err := InsertPriceTx(transaction, price)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var prices []*Price
|
||||||
|
|
||||||
|
_, err := transaction.Select(&prices, "SELECT * from prices where SecurityId=? AND CurrencyId=? AND Date=? AND Value=?", price.SecurityId, price.CurrencyId, price.Date, price.Value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(prices) > 0 {
|
||||||
|
return nil // price already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
err = InsertPriceTx(transaction, price)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the latest price for security in currency units before date
|
||||||
|
func GetLatestPrice(transaction *gorp.Transaction, security, currency *Security, date *time.Time) (*Price, error) {
|
||||||
|
var p Price
|
||||||
|
err := transaction.SelectOne(&p, "SELECT * from prices where SecurityId=? AND CurrencyId=? AND Date <= ? ORDER BY Date DESC LIMIT 1", security.SecurityId, currency.SecurityId, date)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the earliest price for security in currency units after date
|
||||||
|
func GetEarliestPrice(transaction *gorp.Transaction, security, currency *Security, date *time.Time) (*Price, error) {
|
||||||
|
var p Price
|
||||||
|
err := transaction.SelectOne(&p, "SELECT * from prices where SecurityId=? AND CurrencyId=? AND Date >= ? ORDER BY Date ASC LIMIT 1", security.SecurityId, currency.SecurityId, date)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the price for security in currency closest to date
|
||||||
|
func GetClosestPriceTx(transaction *gorp.Transaction, security, currency *Security, date *time.Time) (*Price, error) {
|
||||||
|
earliest, _ := GetEarliestPrice(transaction, security, currency, date)
|
||||||
|
latest, err := GetLatestPrice(transaction, security, currency, date)
|
||||||
|
|
||||||
|
// Return early if either earliest or latest are invalid
|
||||||
|
if earliest == nil {
|
||||||
|
return latest, err
|
||||||
|
} else if err != nil {
|
||||||
|
return earliest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
howlate := earliest.Date.Sub(*date)
|
||||||
|
howearly := date.Sub(latest.Date)
|
||||||
|
if howearly < howlate {
|
||||||
|
return latest, nil
|
||||||
|
} else {
|
||||||
|
return earliest, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClosestPrice(security, currency *Security, date *time.Time) (*Price, error) {
|
||||||
|
transaction, err := DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
price, err := GetClosestPriceTx(transaction, security, currency, date)
|
||||||
|
if err != nil {
|
||||||
|
transaction.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = transaction.Commit()
|
||||||
|
if err != nil {
|
||||||
|
transaction.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return price, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
q, err := finance.GetQuote("BRK-A")
|
||||||
|
if err == nil {
|
||||||
|
fmt.Printf("%+v", q)
|
||||||
|
}
|
||||||
|
}
|
91
prices_lua.go
Normal file
91
prices_lua.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
const luaPriceTypeName = "price"
|
||||||
|
|
||||||
|
func luaRegisterPrices(L *lua.LState) {
|
||||||
|
mt := L.NewTypeMetatable(luaPriceTypeName)
|
||||||
|
L.SetGlobal("price", mt)
|
||||||
|
L.SetField(mt, "__index", L.NewFunction(luaPrice__index))
|
||||||
|
L.SetField(mt, "__tostring", L.NewFunction(luaPrice__tostring))
|
||||||
|
L.SetField(mt, "__metatable", lua.LString("protected"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func PriceToLua(L *lua.LState, price *Price) *lua.LUserData {
|
||||||
|
ud := L.NewUserData()
|
||||||
|
ud.Value = price
|
||||||
|
L.SetMetatable(ud, L.GetTypeMetatable(luaPriceTypeName))
|
||||||
|
return ud
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks whether the first lua argument is a *LUserData with *Price and returns this *Price.
|
||||||
|
func luaCheckPrice(L *lua.LState, n int) *Price {
|
||||||
|
ud := L.CheckUserData(n)
|
||||||
|
if price, ok := ud.Value.(*Price); ok {
|
||||||
|
return price
|
||||||
|
}
|
||||||
|
L.ArgError(n, "price expected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaPrice__index(L *lua.LState) int {
|
||||||
|
p := luaCheckPrice(L, 1)
|
||||||
|
field := L.CheckString(2)
|
||||||
|
|
||||||
|
switch field {
|
||||||
|
case "PriceId", "priceid":
|
||||||
|
L.Push(lua.LNumber(float64(p.PriceId)))
|
||||||
|
case "Security", "security":
|
||||||
|
security_map, err := luaContextGetSecurities(L)
|
||||||
|
if err != nil {
|
||||||
|
panic("luaContextGetSecurities couldn't fetch securities")
|
||||||
|
}
|
||||||
|
s, ok := security_map[p.SecurityId]
|
||||||
|
if !ok {
|
||||||
|
panic("Price's security not found for user")
|
||||||
|
}
|
||||||
|
L.Push(SecurityToLua(L, s))
|
||||||
|
case "Currency", "currency":
|
||||||
|
security_map, err := luaContextGetSecurities(L)
|
||||||
|
if err != nil {
|
||||||
|
panic("luaContextGetSecurities couldn't fetch securities")
|
||||||
|
}
|
||||||
|
c, ok := security_map[p.CurrencyId]
|
||||||
|
if !ok {
|
||||||
|
panic("Price's currency not found for user")
|
||||||
|
}
|
||||||
|
L.Push(SecurityToLua(L, c))
|
||||||
|
case "Value", "value":
|
||||||
|
amt, err := GetBigAmount(p.Value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
float, _ := amt.Float64()
|
||||||
|
L.Push(lua.LNumber(float))
|
||||||
|
default:
|
||||||
|
L.ArgError(2, "unexpected price attribute: "+field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaPrice__tostring(L *lua.LState) int {
|
||||||
|
p := luaCheckPrice(L, 1)
|
||||||
|
|
||||||
|
security_map, err := luaContextGetSecurities(L)
|
||||||
|
if err != nil {
|
||||||
|
panic("luaContextGetSecurities couldn't fetch securities")
|
||||||
|
}
|
||||||
|
s, ok1 := security_map[p.SecurityId]
|
||||||
|
c, ok2 := security_map[p.CurrencyId]
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
panic("Price's currency or security not found for user")
|
||||||
|
}
|
||||||
|
|
||||||
|
L.Push(lua.LString(p.Value + " " + c.Symbol + " (" + s.Symbol + ")"))
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
@ -161,6 +161,7 @@ func runReport(user *User, report *Report) (*Tabulation, error) {
|
|||||||
luaRegisterBalances(L)
|
luaRegisterBalances(L)
|
||||||
luaRegisterDates(L)
|
luaRegisterDates(L)
|
||||||
luaRegisterTabulations(L)
|
luaRegisterTabulations(L)
|
||||||
|
luaRegisterPrices(L)
|
||||||
|
|
||||||
err := L.DoString(report.Lua)
|
err := L.DoString(report.Lua)
|
||||||
|
|
||||||
|
@ -135,6 +135,8 @@ func luaSecurity__index(L *lua.LState) int {
|
|||||||
L.Push(lua.LNumber(float64(a.Precision)))
|
L.Push(lua.LNumber(float64(a.Precision)))
|
||||||
case "Type", "type":
|
case "Type", "type":
|
||||||
L.Push(lua.LNumber(float64(a.Type)))
|
L.Push(lua.LNumber(float64(a.Type)))
|
||||||
|
case "ClosestPrice", "closestprice":
|
||||||
|
L.Push(L.NewFunction(luaClosestPrice))
|
||||||
default:
|
default:
|
||||||
L.ArgError(2, "unexpected security attribute: "+field)
|
L.ArgError(2, "unexpected security attribute: "+field)
|
||||||
}
|
}
|
||||||
@ -142,6 +144,21 @@ func luaSecurity__index(L *lua.LState) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func luaClosestPrice(L *lua.LState) int {
|
||||||
|
s := luaCheckSecurity(L, 1)
|
||||||
|
c := luaCheckSecurity(L, 2)
|
||||||
|
date := luaCheckTime(L, 3)
|
||||||
|
|
||||||
|
p, err := GetClosestPrice(s, c, date)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
} else {
|
||||||
|
L.Push(PriceToLua(L, p))
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
func luaSecurity__tostring(L *lua.LState) int {
|
func luaSecurity__tostring(L *lua.LState) int {
|
||||||
s := luaCheckSecurity(L, 1)
|
s := luaCheckSecurity(L, 1)
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user