implemented persistence
Generate check / check-changes (pull_request) Successful in 36s
Quality / check-changes (pull_request) Successful in 35s
Generate check / verify-generate (pull_request) Successful in 48s
Quality / run-tests (pull_request) Successful in 51s

This commit is contained in:
2026-08-02 15:53:27 +01:00
parent 6fcc21adfe
commit 5fe47746fa
15 changed files with 1447 additions and 48 deletions
+88
View File
@@ -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 }