Handle spin-off events
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
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
This commit is contained in:
1 parent
4681a34933
commit
f1a7f777ef
3 files changed
+288
-66
No files matched your search
+175
-62
@@ -92,6 +92,10 @@ const (
|
||||
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
|
||||
|
||||
@@ -101,78 +105,53 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
|
||||
return Record{}, fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
if strings.ToLower(raw[0]) == "action" {
|
||||
action := strings.ToLower(raw[0])
|
||||
if action == "action" { // header row
|
||||
continue
|
||||
}
|
||||
|
||||
symbol := raw[2]
|
||||
switch action {
|
||||
case MarketBuy, LimitBuy:
|
||||
return rr.parseBuyRecord(ctx, raw)
|
||||
|
||||
qant, err := parseDecimal(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)
|
||||
}
|
||||
|
||||
var kind internal.Kind
|
||||
switch strings.ToLower(raw[0]) {
|
||||
case MarketBuy, LimitBuy, SpinOff:
|
||||
kind = internal.KindBuy
|
||||
case MarketSell, LimitSell:
|
||||
kind = internal.KindSell
|
||||
return rr.parseSellRecord(ctx, raw)
|
||||
|
||||
case SpinOff:
|
||||
return rr.parseSpinOff(ctx, raw)
|
||||
|
||||
case StockSplitOpen:
|
||||
if splitRec != nil {
|
||||
return nil, fmt.Errorf("split already open")
|
||||
}
|
||||
|
||||
splitRec = &splitRecord{
|
||||
Record: Record{
|
||||
symbol: symbol,
|
||||
kind: internal.KindSplit,
|
||||
quantity: qant,
|
||||
price: price,
|
||||
fees: conversionFee,
|
||||
taxes: stampDutyTax.Add(frenchTxTax),
|
||||
timestamp: ts,
|
||||
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
|
||||
},
|
||||
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")
|
||||
}
|
||||
splitRec.ratio = splitRec.Record.Quantity().Div(qant)
|
||||
|
||||
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", symbol),
|
||||
slog.Any("ISIN", raw[2]),
|
||||
slog.Any("Quantity", qant),
|
||||
)
|
||||
continue
|
||||
@@ -180,20 +159,140 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
|
||||
default:
|
||||
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
|
||||
}
|
||||
|
||||
return Record{
|
||||
symbol: symbol,
|
||||
kind: kind,
|
||||
quantity: qant,
|
||||
price: price,
|
||||
fees: conversionFee,
|
||||
taxes: stampDutyTax.Add(frenchTxTax),
|
||||
timestamp: ts,
|
||||
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -231,6 +330,20 @@ func parseOptionalDecimal(s string) (decimal.Decimal, error) {
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user