Skip to content

Commit

Permalink
Documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
thinktwice13 committed Jun 10, 2022
1 parent aec45d0 commit ec2ee2e
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 118 deletions.
62 changes: 49 additions & 13 deletions import_results.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@ type Instrument struct {
Category string
}

type AssetImport struct {
Instrument
Trades []Trade
Dividends, WithholdingTax []Transaction
}

type ImportResults struct {
assets assets

// List of transactions not related to any specific asset
// i.e. broker subsctiptions
fees []Transaction

// Mapped unique currencies and years found in any imported events
currencies map[string]bool
years map[int]bool
}

// Domicile extracts the instrument country code used fo the ForeignIncome tax report
// Requires ISIN to be one of the symbols to work properly
// Fallback if the first symbol foundn in the symbols array
func (i *Instrument) Domicile() string {
var isin string
for _, symbol := range i.Symbols {
Expand All @@ -38,19 +59,9 @@ func (i *Instrument) Domicile() string {
return isin[:2]
}

type AssetImport struct {
Instrument
Trades []Trade
Dividends, WithholdingTax []Transaction
}

type ImportResults struct {
assets assets
fees []Transaction
currencies map[string]bool
years map[int]bool
}

// NewImportResults initializes new import results struct
// Sets default surrencies
// Sets default year to current year
func NewImportResults() *ImportResults {
return &ImportResults{
assets: map[string]*AssetImport{},
Expand Down Expand Up @@ -113,6 +124,11 @@ func (r *ImportResults) AddFee(ccy string, amt float64, yr int) {

}

// bySymbols func looks up any assets already imported by incoming symbols
// If none found, creates new asset and maps to ll its symbols
// If at least one symbol is matched with existing assets, merges information and all the symbols
//
// If incoming symbols are matched with more than one distinct asset, merges conflict and uses the first found match
func (as assets) bySymbols(ss ...string) *AssetImport {
if len(ss) == 0 {
return nil
Expand Down Expand Up @@ -165,6 +181,7 @@ func (as assets) bySymbols(ss ...string) *AssetImport {
return base
}

// mergeAsset merges information on the assets and jions all founc events: trades, dividends and withholding tax tax
func mergeAsset(src AssetImport, tgt *AssetImport) {
found := make(map[string]bool, len(src.Symbols))
for _, symbol := range tgt.Symbols {
Expand All @@ -190,6 +207,7 @@ func mergeAsset(src AssetImport, tgt *AssetImport) {
tgt.Dividends = append(tgt.Dividends, src.Dividends...)
}

// assets maps imported asset information by symbol, for easier lookup while importing
type assets map[string]*AssetImport

func (a assets) list() []AssetImport {
Expand All @@ -206,3 +224,21 @@ func (a assets) list() []AssetImport {

return list
}

type YearAmount struct {
Amount float64
Year int
}

func convertFees(fees []Transaction, r Rater) []YearAmount {
converted := make([]YearAmount, len(fees))
for i := range fees {
f := &fees[i]
converted[i] = YearAmount{
Amount: f.Amount * r.Rate(f.Currency, f.Year),
Year: f.Year,
}
}
return converted

}
43 changes: 19 additions & 24 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,43 @@ import (
"fmt"
"ibkr-report/fx"
"log"
"os"
"time"
)

func main() {
fmt.Println("Hello World")
t := time.Now()
assets, fees, years, currencies := readDir()

if len(assets) == 0 {
fmt.Println("No data found. Exiting")
os.Exit(0)
}

// Fetch currency conversion rates per year
rates, err := fx.New(currencies, years)
if err != nil {
log.Fatalln(err)
}

// Summarize imported asset events by year
summaries := summarizeAssets(assets, rates)
print(len(summaries))
// PrettyPrint(summaries)

// Convert global fees (not related to asset events)
convFees := convertFees(fees, rates)
tr := taxReport(summaries, convFees, len(years))
fmt.Println(len(tr))

PrettyPrint(tr)
// Build tax report from asset summaries
tr := taxReport(summaries, convFees, len(years))

// Write asset summaries and tax reports to spreadsheet
r := NewReport("Portfolio Report")
tr.WriteTo(r)
summaries.WriteTo(r)
r.Save()
}

type YearAmount struct {
Amount float64
Year int
}

func convertFees(fees []Transaction, r Rater) []YearAmount {
converted := make([]YearAmount, len(fees))
for i := range fees {
f := &fees[i]
converted[i] = YearAmount{
Amount: f.Amount * r.Rate(f.Currency, f.Year),
Year: f.Year,
}
err = r.Save()
if err != nil {
log.Fatalln(err)
}
return converted

fmt.Println("Finished in:", time.Since(t))
}

func PrettyPrint(a any) {
Expand Down
32 changes: 28 additions & 4 deletions read_dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"time"
)

// readDir imports all data found in curent directory
func readDir() ([]AssetImport, []Transaction, []int, []string) {
files := findFiles()
ir := NewImportResults()
Expand All @@ -25,6 +26,7 @@ func readDir() ([]AssetImport, []Transaction, []int, []string) {
return ir.assets.list(), ir.fees, list(ir.years), list(ir.currencies)
}

// findFiles walks the current directory and looks for .csv files
func findFiles() []string {
var files []string
err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
Expand All @@ -49,6 +51,8 @@ func findFiles() []string {
return files
}

// ReadStatement reads single csv IBKR file
// Filters csv lines by relevant sections and uses *ImportResults to send files to
func ReadStatement(filename string, ir *ImportResults) {
file, err := os.Open(filename)
if err != nil {
Expand All @@ -60,7 +64,9 @@ func ReadStatement(filename string, ir *ImportResults) {
reader.FieldsPerRecord = -1 // Disable record length test in the CSV
reader.LazyQuotes = true // Allow quote in unquoted field

// Not all csv lines are neded. Get new section handlers for each file
sections := ibkrSections()
// header slice keeps the csv line used as a header for the section currently being read
var header []string
for {
line, err := reader.Read()
Expand All @@ -72,6 +78,8 @@ func ReadStatement(filename string, ir *ImportResults) {
continue
}

// Section is recognized by the first field item in the line slice
// If not found in the ibkr line handlers map, ignore entire line
handle, ok := sections[line[0]]
if !ok {
continue
Expand All @@ -97,6 +105,7 @@ func handleLine(m map[string]string) {
fmt.Println(m)
}

// ibkrSections returns map of IBKR csv line handlers mapped by relevant section
func ibkrSections() map[string]lineHandler {
return map[string]lineHandler{
"Financial Instrument Information": handleInstrumentLine,
Expand All @@ -107,6 +116,7 @@ func ibkrSections() map[string]lineHandler {
}
}

// mapLine uses a csv line and a related header line to construct a value to field map for easier field lookup while importing lines
func mapLine(data, header []string) (map[string]string, error) {
if header == nil {
return nil, errors.New("cannot convert to row from empty header")
Expand All @@ -126,6 +136,7 @@ func mapLine(data, header []string) (map[string]string, error) {
return lm, nil
}

// handleInstrumentLine handles the instrument information lines of the IBKR csv statement
func handleInstrumentLine(lm map[string]string, ir *ImportResults) {
symbols := append(strings.Split(strings.ReplaceAll(lm["Symbol"], " ", ""), ","), formatISIN(lm["Security ID"]))
if len(symbols) == 0 {
Expand Down Expand Up @@ -155,6 +166,7 @@ func formatISIN(sID string) string {
return sID
}

// handleTradeLine handles the trade lines of the IBKR csv statement
func handleTradeLine(lm map[string]string, ir *ImportResults) {
if lm["Date/Time"] == "" || lm["Asset Category"] == "Forex" || lm["Symbol"] == "" {
return
Expand All @@ -163,6 +175,8 @@ func handleTradeLine(lm map[string]string, ir *ImportResults) {
c := lm["Currency"]
ir.AddTrade(lm["Symbol"], c, t, amountFromString(lm["Quantity"]), amountFromString(lm["T. Price"]), amountFromString(lm["Comm/Fee"]))
}

// handleDividendLine handles the dividend lines of the IBKR csv statement
func handleDividendLine(lm map[string]string, ir *ImportResults) {
if lm["Date"] == "" {
return
Expand All @@ -174,6 +188,7 @@ func handleDividendLine(lm map[string]string, ir *ImportResults) {
ir.AddDividend(symbol, lm["Currency"], yearFromDate(lm["Date"]), amountFromString(lm["Amount"]), false)
}

// yearFromDate extracts a year from IBKR csv date field
func yearFromDate(s string) int {
y, err := strconv.Atoi(s[:4])
if err != nil {
Expand All @@ -182,6 +197,8 @@ func yearFromDate(s string) int {
return y
}

// handleWithholdingTaxLine handles the withohlding tax lines of the IBKR csv statement
// TODO reuse dividend line handler
func handleWithholdingTaxLine(lm map[string]string, ir *ImportResults) {
if lm["Date"] == "" {
return
Expand All @@ -194,6 +211,8 @@ func handleWithholdingTaxLine(lm map[string]string, ir *ImportResults) {

ir.AddDividend(symbol, lm["Currency"], yearFromDate(lm["Date"]), amountFromString(lm["Amount"]), true)
}

// handleFeeLine handles the fee lines of the IBKR csv statement
func handleFeeLine(lm map[string]string, ir *ImportResults) {
if lm["Date"] == "" {
return
Expand All @@ -202,6 +221,7 @@ func handleFeeLine(lm map[string]string, ir *ImportResults) {
ir.AddFee(lm["Currency"], amountFromString(lm["Amount"]), yearFromDate(lm["Date"]))
}

// symbolFromDescription extracts a symbol from IBKR csv dividend lines
func symbolFromDescription(d string) (string, error) {
if d == "" {
return "", errors.New("cannot create asset event without symbol")
Expand All @@ -220,7 +240,9 @@ func symbolFromDescription(d string) (string, error) {
return symbol, nil
}

func amountFromString(s string) (v float64) {
// amountFromString formats number strings to float64 type
func amountFromString(s string) float64 {
var v float64
if s == "" {
log.Fatalf("Cannot create amount from %s", s)
}
Expand All @@ -229,9 +251,10 @@ func amountFromString(s string) (v float64) {
if err != nil {
log.Printf("error parsing float from from %v", err)
}
return
return v
}

// timeFromExact extracts time.Time from IBKR csv time field
func timeFromExact(t string) time.Time {
timeStr := strings.Join(strings.Split(t, ","), "")
tm, err := time.Parse("2006-01-02 15:04:05", timeStr)
Expand All @@ -242,11 +265,12 @@ func timeFromExact(t string) time.Time {
return tm
}

type Key interface {
type key interface {
string | float64 | int
}

func list[T Key](m map[T]bool) []T {
// list returns a list of map keys if the key implements key interface
func list[T key](m map[T]bool) []T {
var l []T
for k := range m {
l = append(l, k)
Expand Down
8 changes: 8 additions & 0 deletions report_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package main
import (
"fmt"
"github.com/xuri/excelize/v2"
"math"
"strconv"
)

// Report extends excelize.File type with custom WriteTo method to implement RowWriter interface needed by the reports to be written
type Report struct {
f *excelize.File
filename string
Expand Down Expand Up @@ -37,3 +39,9 @@ func (r *Report) Save() error {
func NewReport(filename string) *Report {
return &Report{f: excelize.NewFile(), filename: filename + ".xlsx"}
}

// RoundDec rounds a float number to provided number of decimal places
func RoundDec(v float64, places int) float64 {
f := math.Pow(10, float64(places))
return math.Round(v*f) / f
}
Loading

0 comments on commit ec2ee2e

Please sign in to comment.