Generate check / check-changes (pull_request) Successful in 25s
Generate check / verify-generate (pull_request) Skipped
Quality / check-changes (pull_request) Successful in 2s
Quality / static-checks (pull_request) Successful in 54s
Quality / run-tests (pull_request) Successful in 54s
380 lines
10 KiB
Go
380 lines
10 KiB
Go
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
|
|
}
|