package trading212 import ( "context" "encoding/csv" "fmt" "io" "log/slog" "strings" "sync" "time" "github.com/biter777/countries" "github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal/ofigi" "github.com/shopspring/decimal" ) type Record struct { symbol string timestamp time.Time kind internal.Kind quantity decimal.Decimal price decimal.Decimal fees decimal.Decimal taxes decimal.Decimal // natureGetter allows us to defer the operation of figuring out the nature to only when/if needed. natureGetter func() internal.Nature } func (r Record) Symbol() string { return r.symbol } func (r Record) Timestamp() time.Time { return r.timestamp } func (r Record) BrokerCountry() int64 { return int64(Country) } func (r Record) AssetCountry() int64 { return int64(countries.ByName(r.Symbol()[:2]).Info().Code) } func (r Record) Kind() internal.Kind { return r.kind } func (r Record) Quantity() decimal.Decimal { return r.quantity } func (r Record) Price() decimal.Decimal { return r.price } func (r Record) Fees() decimal.Decimal { return r.fees } func (r Record) Taxes() decimal.Decimal { return r.taxes } func (r Record) Nature() internal.Nature { return r.natureGetter() } type RecordReader struct { reader *csv.Reader figi *ofigi.Client } func NewRecordReader(r io.Reader, f *ofigi.Client) *RecordReader { return &RecordReader{ reader: csv.NewReader(r), figi: f, } } const ( MarketBuy = "market buy" MarketSell = "market sell" LimitBuy = "limit buy" LimitSell = "limit sell" StockSplitOpen = "stock split open" StockSplitClose = "stock split close" StokDistribution = "stock distribution" SpinOff = "spin off" ) // ReadRecord reads the next record from the statement, dispatching each // action to the parser that validates exactly the columns that matter for // it. Split open/close rows are paired up here because the pairing spans // rows: the ratio is only known once the close row is seen. func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) { var splitRec *splitRecord for { raw, err := rr.reader.Read() if err != nil { return Record{}, fmt.Errorf("read record: %w", err) } action := strings.ToLower(raw[0]) if action == "action" { // header row continue } switch action { case MarketBuy, LimitBuy: return rr.parseBuyRecord(ctx, raw) case MarketSell, LimitSell: return rr.parseSellRecord(ctx, raw) case SpinOff: return rr.parseSpinOff(ctx, raw) case StockSplitOpen: if splitRec != nil { return nil, fmt.Errorf("split already open") } rec, err := rr.parseTradeRecord(ctx, raw) if err != nil { return nil, err } rec.kind = internal.KindSplit splitRec = &splitRecord{Record: rec} continue case StockSplitClose: if splitRec == nil { return nil, fmt.Errorf("missing split open") } closeQty, err := parsePositiveDecimal(raw[6]) if err != nil { return nil, fmt.Errorf("parse record quantity: %w", err) } splitRec.ratio = splitRec.Record.Quantity().Div(closeQty) return splitRec, nil case StokDistribution: qant, err := parsePositiveDecimal(raw[6]) if err != nil { return Record{}, fmt.Errorf("parse record quantity: %w", err) } slog.Warn("Skipping 'stock distribution' event!", slog.Any("ISIN", raw[2]), slog.Any("Quantity", qant), ) continue default: return Record{}, fmt.Errorf("parse record type: %s", raw[0]) } } } // parseBuyRecord parses a market/limit buy row into a KindBuy Record. func (rr RecordReader) parseBuyRecord(ctx context.Context, raw []string) (Record, error) { rec, err := rr.parseTradeRecord(ctx, raw) if err != nil { return Record{}, err } rec.kind = internal.KindBuy return rec, nil } // parseSellRecord parses a market/limit sell row into a KindSell Record. func (rr RecordReader) parseSellRecord(ctx context.Context, raw []string) (Record, error) { rec, err := rr.parseTradeRecord(ctx, raw) if err != nil { return Record{}, err } rec.kind = internal.KindSell return rec, nil } // parseTradeRecord parses the columns shared by trade-shaped rows (quantity, // price, timestamp, fees and taxes) into a Record with an unset Kind, // leaving the kind decision to the caller. Split-open rows reuse it because // their row shape is identical. func (rr RecordReader) parseTradeRecord(ctx context.Context, raw []string) (Record, error) { qant, err := parsePositiveDecimal(raw[6]) if err != nil { return Record{}, fmt.Errorf("parse record quantity: %w", err) } price, err := parseDecimal(raw[7]) if err != nil { return Record{}, fmt.Errorf("parse record price: %w", err) } ts, err := parseTimestamp(raw[1]) if err != nil { return Record{}, fmt.Errorf("parse record timestamp: %w", err) } conversionFee, err := parseOptionalDecimal(raw[16]) if err != nil { return Record{}, fmt.Errorf("parse record conversion fee: %w", err) } stampDutyTax, err := parseOptionalDecimal(raw[14]) if err != nil { return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err) } frenchTxTax, err := parseOptionalDecimal(raw[18]) if err != nil { return Record{}, fmt.Errorf("parse record french transaction tax: %w", err) } return Record{ symbol: raw[2], quantity: qant, price: price, fees: conversionFee, taxes: stampDutyTax.Add(frenchTxTax), timestamp: ts, natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]), }, nil } // parseSpinOff books a 'Spin off' statement row as a zero-cost buy. The // zero-cost interpretation is only sound when no money moved with the // distribution — see README.md in this package for the rationale — so every // cost column is parsed as optional (an empty column means zero) and // required to be zero: anything unexpected is logged and returned as an // error so the run stops instead of silently booking a wrong lot. func (rr RecordReader) parseSpinOff(ctx context.Context, raw []string) (Record, error) { symbol := raw[2] qant, err := parsePositiveDecimal(raw[6]) if err != nil { return Record{}, fmt.Errorf("parse record quantity: %w", err) } ts, err := parseTimestamp(raw[1]) if err != nil { return Record{}, fmt.Errorf("parse record timestamp: %w", err) } // A slice (not a map) keeps validation and error reporting deterministic. // result and total are only ever read here, for this row kind. costs := []struct { name string column int }{ {"price", 7}, {"result", 10}, {"total", 12}, {"stamp duty reserve tax", 14}, {"currency conversion fee", 16}, {"french transaction tax", 18}, } for _, cost := range costs { value, err := parseOptionalDecimal(raw[cost.column]) if err != nil { return Record{}, fmt.Errorf("parse record %s: %w", cost.name, err) } if !value.IsZero() { reason := fmt.Errorf("spin off with a non-zero %s (%s) for ISIN %s: refusing to book it as a zero-cost buy", cost.name, value, symbol) slog.Error("Unexpected 'spin off' row!", slog.Any("err", reason), slog.Any("ISIN", symbol), ) return Record{}, reason } } slog.Info("Booking 'spin off' event as a zero-cost buy!", slog.Any("ISIN", symbol), slog.Any("Quantity", qant), ) return Record{ symbol: symbol, kind: internal.KindBuy, quantity: qant, price: decimal.Zero, fees: decimal.Zero, taxes: decimal.Zero, timestamp: ts, natureGetter: figiNatureGetter(ctx, rr.figi, symbol), }, nil } func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature { return sync.OnceValue(func() internal.Nature { secType, err := of.SecurityTypeByISIN(ctx, isin) if err != nil { slog.Error("failed to get security type by ISIN", slog.Any("err", err), slog.String("isin", isin)) return internal.NatureUnknown } switch secType { case "Common Stock", "ADR", "REIT": return internal.NatureG01 case "ETP": return internal.NatureG20 default: slog.Error("got unsupported security type for ISIN", slog.String("isin", isin), slog.String("securityType", secType)) return internal.NatureUnknown } }) } // parseFloat attempts to parse a string using a standard precision and rounding mode. // Using this function helps avoid issues around converting values due to minor parameter changes. func parseDecimal(s string) (decimal.Decimal, error) { return decimal.NewFromString(s) } // parseOptionalDecimal behaves the same as parseDecimal but returns 0 when len(s) is 0 instead of // error. // Using this function helps avoid issues around converting values due to minor parameter changes. func parseOptionalDecimal(s string) (decimal.Decimal, error) { if len(s) == 0 { return decimal.Decimal{}, nil } return parseDecimal(s) } // parsePositiveDecimal parses s as a decimal and requires it to be strictly // positive: empty, malformed, zero and negative values are rejected. A lot // (or a split-ratio denominator) is meaningless without a positive quantity. func parsePositiveDecimal(s string) (decimal.Decimal, error) { value, err := parseDecimal(s) if err != nil { return decimal.Decimal{}, err } if !value.IsPositive() { return decimal.Decimal{}, fmt.Errorf("non-positive value %q", s) } return value, nil } // parseTimestamp parses a Trading212 timestamp string. Trading212 has shipped // rows both without a timezone (treated here as UTC) and with an explicit // numeric offset (e.g. "+00:00", "-05:00", "+01:00") or a "Z" UTC marker. // The layouts are tried in order; the first match wins. func parseTimestamp(s string) (time.Time, error) { layouts := [3]string{ "2006-01-02 15:04:05-07:00", // numeric offset time.DateTime, // "2006-01-02 15:04:05" "2006-01-02 15:04:05Z", // explicit UTC marker } var ( lastErr error ts time.Time ) for _, layout := range layouts { ts, lastErr = time.Parse(layout, s) if lastErr == nil { return ts, nil } } return time.Time{}, lastErr } type splitRecord struct { Record ratio decimal.Decimal } func (sr splitRecord) Quantity() decimal.Decimal { return sr.ratio }