implemented persistence
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
package trading212
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// recordState is the on-disk representation of a trading212.Record.
|
||||
// Nature is intentionally omitted because it is resolved lazily via the
|
||||
// OpenFIGI client on demand.
|
||||
type recordState struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Kind internal.Kind `json:"kind"`
|
||||
Quantity decimal.Decimal `json:"quantity"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
Fees decimal.Decimal `json:"fees"`
|
||||
Taxes decimal.Decimal `json:"taxes"`
|
||||
}
|
||||
|
||||
// RecordSerializer encodes and decodes trading212.Record values for the
|
||||
// internal persistence layer. On decode it re-wires the lazy natureGetter to
|
||||
// the supplied OpenFIGI client so a restored Record resolves Nature() via a
|
||||
// fresh API call when needed.
|
||||
type RecordSerializer struct {
|
||||
figi *ofigi.Client
|
||||
}
|
||||
|
||||
// NewRecordSerializer returns a RecordSerializer that uses figi to resolve
|
||||
// Record.Nature() on load.
|
||||
func NewRecordSerializer(figi *ofigi.Client) *RecordSerializer {
|
||||
return &RecordSerializer{figi: figi}
|
||||
}
|
||||
|
||||
// MarshalRecord encodes the given internal.Record as JSON. It returns an
|
||||
// error if the record is not a trading212.Record (or a *trading212.Record).
|
||||
func (s *RecordSerializer) MarshalRecord(_ context.Context, r internal.Record) ([]byte, error) {
|
||||
var rec Record
|
||||
switch v := r.(type) {
|
||||
case Record:
|
||||
rec = v
|
||||
case *Record:
|
||||
if v == nil {
|
||||
return nil, fmt.Errorf("trading212: cannot marshal nil *trading212.Record")
|
||||
}
|
||||
rec = *v
|
||||
default:
|
||||
return nil, fmt.Errorf("trading212: cannot marshal %T as trading212.Record", r)
|
||||
}
|
||||
|
||||
state := recordState{
|
||||
Symbol: rec.symbol,
|
||||
Timestamp: rec.timestamp,
|
||||
Kind: rec.kind,
|
||||
Quantity: rec.quantity,
|
||||
Price: rec.price,
|
||||
Fees: rec.fees,
|
||||
Taxes: rec.taxes,
|
||||
}
|
||||
|
||||
return json.Marshal(state)
|
||||
}
|
||||
|
||||
// UnmarshalRecord decodes a JSON-encoded trading212.Record and re-wires its
|
||||
// natureGetter to the serializer's OpenFIGI client.
|
||||
func (s *RecordSerializer) UnmarshalRecord(ctx context.Context, data []byte) (internal.Record, error) {
|
||||
var state recordState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal trading212 record: %w", err)
|
||||
}
|
||||
|
||||
return Record{
|
||||
symbol: state.Symbol,
|
||||
timestamp: state.Timestamp,
|
||||
kind: state.Kind,
|
||||
quantity: state.Quantity,
|
||||
price: state.Price,
|
||||
fees: state.Fees,
|
||||
taxes: state.Taxes,
|
||||
natureGetter: figiNatureGetter(ctx, s.figi, state.Symbol),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package trading212
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
func TestRecordSerializer_RoundTrip(t *testing.T) {
|
||||
want := Record{
|
||||
symbol: "XX1234567890",
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||
kind: internal.KindBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
}
|
||||
|
||||
s := NewRecordSerializer(NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
||||
|
||||
data, err := s.MarshalRecord(t.Context(), want)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.UnmarshalRecord(t.Context(), data)
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
if got.Symbol() != want.Symbol() {
|
||||
t.Errorf("Symbol: want %q but got %q", want.Symbol(), got.Symbol())
|
||||
}
|
||||
if got.Kind() != want.Kind() {
|
||||
t.Errorf("Kind: want %v but got %v", want.Kind(), got.Kind())
|
||||
}
|
||||
if !got.Price().Equal(want.Price()) {
|
||||
t.Errorf("Price: want %v but got %v", want.Price(), got.Price())
|
||||
}
|
||||
if !got.Quantity().Equal(want.Quantity()) {
|
||||
t.Errorf("Quantity: want %v but got %v", want.Quantity(), got.Quantity())
|
||||
}
|
||||
if !got.Fees().Equal(want.Fees()) {
|
||||
t.Errorf("Fees: want %v but got %v", want.Fees(), got.Fees())
|
||||
}
|
||||
if !got.Taxes().Equal(want.Taxes()) {
|
||||
t.Errorf("Taxes: want %v but got %v", want.Taxes(), got.Taxes())
|
||||
}
|
||||
if !got.Timestamp().Equal(want.Timestamp()) {
|
||||
t.Errorf("Timestamp: want %v but got %v", want.Timestamp(), got.Timestamp())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSerializer_UnmarshalRecord_NatureTriggersOpenFIGI(t *testing.T) {
|
||||
var calls int
|
||||
client := &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
s := NewRecordSerializer(ofigi.NewOpenFIGI(client, ""))
|
||||
|
||||
original := Record{
|
||||
symbol: "XX1234567890",
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||
kind: internal.KindBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
// Pre-populated so MarshalRecord doesn't accidentally trigger an
|
||||
// OpenFIGI call when encoding the original record.
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
}
|
||||
|
||||
data, err := s.MarshalRecord(t.Context(), original)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
if calls != 0 {
|
||||
t.Fatalf("OpenFIGI called during MarshalRecord: %d", calls)
|
||||
}
|
||||
|
||||
got, err := s.UnmarshalRecord(t.Context(), data)
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
// Nature must not have been resolved yet — natureGetter is lazy.
|
||||
if calls != 0 {
|
||||
t.Fatalf("OpenFIGI called before Nature(): %d", calls)
|
||||
}
|
||||
|
||||
if nature := got.Nature(); nature != internal.NatureG01 {
|
||||
t.Errorf("Nature: want %v but got %v", internal.NatureG01, nature)
|
||||
}
|
||||
|
||||
if calls != 1 {
|
||||
t.Errorf("OpenFIGI request count: want 1 but got %d", calls)
|
||||
}
|
||||
|
||||
// Subsequent Nature() calls should not re-trigger the request (the
|
||||
// underlying sync.OnceValue caches the result on the client too).
|
||||
if nature := got.Nature(); nature != internal.NatureG01 {
|
||||
t.Errorf("Nature (cached): want %v but got %v", internal.NatureG01, nature)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("OpenFIGI request count after re-read: want 1 but got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSerializer_MarshalRecord_WrongType(t *testing.T) {
|
||||
s := NewRecordSerializer(NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
||||
|
||||
_, err := s.MarshalRecord(t.Context(), stubRecord{})
|
||||
if err == nil {
|
||||
t.Fatal("want error but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// stubRecord is a non-trading212 implementation of internal.Record used to
|
||||
// verify that MarshalRecord rejects unrelated record types.
|
||||
type stubRecord struct{}
|
||||
|
||||
func (stubRecord) Symbol() string { return "STUB" }
|
||||
func (stubRecord) Nature() internal.Nature { return internal.NatureUnknown }
|
||||
func (stubRecord) BrokerCountry() int64 { return 0 }
|
||||
func (stubRecord) AssetCountry() int64 { return 0 }
|
||||
func (stubRecord) Kind() internal.Kind { return internal.KindUnknown }
|
||||
func (stubRecord) Price() decimal.Decimal { return decimal.Zero }
|
||||
func (stubRecord) Quantity() decimal.Decimal { return decimal.Zero }
|
||||
func (stubRecord) Timestamp() time.Time { return time.Time{} }
|
||||
func (stubRecord) Fees() decimal.Decimal { return decimal.Zero }
|
||||
func (stubRecord) Taxes() decimal.Decimal { return decimal.Zero }
|
||||
Reference in New Issue
Block a user