implemented persistence
This commit is contained in:
+59
-19
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -13,20 +14,19 @@ import (
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/nmoniz/any2anexoj/internal/trading212"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var (
|
||||
// TODO: once we support more brokers or exchanges we should make this parameter required and
|
||||
// remove/change default
|
||||
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
|
||||
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
|
||||
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
|
||||
format = pflag.StringP("format", "f", "table", "Output format: table or csv")
|
||||
ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)")
|
||||
// TODO: improve documentation on selectors
|
||||
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
|
||||
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
|
||||
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
|
||||
format = pflag.StringP("format", "f", "table", "Output format: table or csv")
|
||||
ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)")
|
||||
selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors: code, assetCountry")
|
||||
stateFile = pflag.String("state-file", "", "Path to a state file for incremental processing")
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -39,12 +39,19 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// run is the production entry point. It wires the CLI flags to runWithIO
|
||||
// using the process's actual stdin/stdout and installs OS-signal-driven
|
||||
// cancellation so a Ctrl-C cancels in-flight processing.
|
||||
func run(ctx context.Context) error {
|
||||
ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt)
|
||||
defer cancel()
|
||||
return runWithIO(ctx, os.Stdin, os.Stdout)
|
||||
}
|
||||
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
// runWithIO is the testable core of the CLI. It reads broker records from
|
||||
// stdin, writes the formatted report to stdout, and optionally persists
|
||||
// incremental state to the path supplied via --state-file.
|
||||
func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
||||
logLevel := slog.LevelInfo
|
||||
if *debug {
|
||||
logLevel = slog.LevelDebug
|
||||
@@ -59,11 +66,18 @@ func run(ctx context.Context) error {
|
||||
return fmt.Errorf("--language flag is required")
|
||||
}
|
||||
|
||||
reader, err := getReader(*platform, *ofAPIKey)
|
||||
figiClient := ofigi.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, *ofAPIKey)
|
||||
|
||||
reader, err := getReader(*platform, stdin, figiClient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting reader: %w", err)
|
||||
}
|
||||
|
||||
store, err := buildStore(*stateFile, *platform, figiClient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writer := internal.NewAggregatorWriter()
|
||||
|
||||
selector, err := internal.ParseSelectors(*selectors)
|
||||
@@ -71,34 +85,60 @@ func run(ctx context.Context) error {
|
||||
return fmt.Errorf("parsing selectors: %w", err)
|
||||
}
|
||||
|
||||
eg.Go(func() error {
|
||||
return internal.BuildReport(ctx, reader, writer, internal.WithSelector(selector))
|
||||
})
|
||||
|
||||
err = eg.Wait()
|
||||
err = internal.BuildReport(
|
||||
ctx,
|
||||
reader,
|
||||
writer,
|
||||
internal.WithSelector(selector),
|
||||
internal.WithStore(store),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch *format {
|
||||
case "csv":
|
||||
return NewCSVWriter(os.Stdout).Render(writer)
|
||||
return NewCSVWriter(stdout).Render(writer)
|
||||
case "table":
|
||||
loc, err := NewLocalizer(*lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create localizer: %w", err)
|
||||
}
|
||||
NewPrettyPrinter(os.Stdout, loc).Render(writer)
|
||||
NewPrettyPrinter(stdout, loc).Render(writer)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported format %q: must be table or csv", *format)
|
||||
}
|
||||
}
|
||||
|
||||
func getReader(platform string, ofAPIKey string) (internal.RecordReader, error) {
|
||||
// buildStore returns the Store implementation that BuildReport should use.
|
||||
// When --state-file is empty an EphemeralStore is used so behaviour is
|
||||
// identical to pre-persistence runs. Otherwise a JSON-backed FileStore is
|
||||
// returned, wired to a platform-specific RecordSerializer.
|
||||
func buildStore(stateFile, platform string, figi *ofigi.Client) (internal.Store, error) {
|
||||
if stateFile == "" {
|
||||
return internal.EphemeralStore{}, nil
|
||||
}
|
||||
|
||||
var serializer internal.RecordSerializer
|
||||
switch platform {
|
||||
case "trading212":
|
||||
return trading212.NewRecordReader(os.Stdin, ofigi.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, ofAPIKey)), nil
|
||||
serializer = trading212.NewRecordSerializer(figi)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported platform for state persistence: %s", platform)
|
||||
}
|
||||
|
||||
store, err := internal.NewFileStore(stateFile, platform, serializer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating file store: %w", err)
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func getReader(platform string, r io.Reader, figi *ofigi.Client) (internal.RecordReader, error) {
|
||||
switch platform {
|
||||
case "trading212":
|
||||
return trading212.NewRecordReader(r, figi), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported platform: %s", platform)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// resetFlags puts every pflag-backed variable back to its default so each
|
||||
// test that calls runWithIO sees a clean slate regardless of test ordering
|
||||
// or arguments the previous test set via pflag.Set.
|
||||
func resetFlags(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := pflag.Set("platform", "trading212"); err != nil {
|
||||
t.Fatalf("reset platform flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("language", "en"); err != nil {
|
||||
t.Fatalf("reset language flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("debug", "false"); err != nil {
|
||||
t.Fatalf("reset debug flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("format", "table"); err != nil {
|
||||
t.Fatalf("reset format flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("open-figi-api-key", ""); err != nil {
|
||||
t.Fatalf("reset open-figi-api-key flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("selectors", ""); err != nil {
|
||||
t.Fatalf("reset selectors flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("state-file", ""); err != nil {
|
||||
t.Fatalf("reset state-file flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// trading212SampleCSV is a minimal Trading212 export with a header row and
|
||||
// one market buy + one matching market sell so BuildReport reaches EOF and
|
||||
// exercises the store.Save path. The line format mirrors the fixtures in
|
||||
// internal/trading212/record_test.go (20 columns).
|
||||
const trading212SampleCSV = `Action,Time,ISIN,Ticker,Name,Notes,Quantity,Price,Price currency,Exchange rate,Result,Result currency,Charges,Charges currency,Stamp duty,Stamp duty currency,Conversion fee,Conversion fee currency,French transaction tax,French transaction tax currency
|
||||
Market buy,2025-07-03 10:44:29,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,
|
||||
Market sell,2025-08-04 11:45:30,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"
|
||||
`
|
||||
|
||||
// runWithStdin runs runWithIO against the supplied stdin payload.
|
||||
func runWithStdin(t *testing.T, stdin string, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
if err := runWithIO(t.Context(), strings.NewReader(stdin), stdout); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestRunWithIO_StateFileCreated verifies that running the CLI with
|
||||
// --state-file produces a state file on disk after a successful EOF.
|
||||
func TestRunWithIO_StateFileCreated(t *testing.T) {
|
||||
resetFlags(t)
|
||||
t.Cleanup(func() { resetFlags(t) })
|
||||
|
||||
dir := t.TempDir()
|
||||
statePath := filepath.Join(dir, "state.json")
|
||||
|
||||
if err := pflag.Set("state-file", statePath); err != nil {
|
||||
t.Fatalf("set state-file flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("format", "csv"); err != nil {
|
||||
t.Fatalf("set format flag: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
|
||||
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
info, err := os.Stat(statePath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected state file at %s but stat returned error: %v", statePath, err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatalf("state file at %s is empty", statePath)
|
||||
}
|
||||
|
||||
// State file must look like JSON with the expected version field.
|
||||
body, err := os.ReadFile(statePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read state file: %v", err)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"version"`)) {
|
||||
t.Errorf("state file missing version field, got: %s", body)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"trading212"`)) {
|
||||
t.Errorf("state file missing trading212 platform, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWithIO_NoStateFileByDefault verifies that omitting --state-file
|
||||
// behaves exactly like the pre-persistence CLI: nothing is written to disk
|
||||
// and the report still renders.
|
||||
func TestRunWithIO_NoStateFileByDefault(t *testing.T) {
|
||||
resetFlags(t)
|
||||
t.Cleanup(func() { resetFlags(t) })
|
||||
|
||||
// Use a temp working directory so any accidental file write would
|
||||
// show up clearly via t.TempDir's cleanup listing.
|
||||
dir := t.TempDir()
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
|
||||
if err := pflag.Set("format", "csv"); err != nil {
|
||||
t.Fatalf("set format flag: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
|
||||
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read tmp dir: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
t.Errorf("unexpected file written without --state-file: %s", e.Name())
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
t.Errorf("expected non-empty csv output on stdout")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWithIO_UnsupportedPlatformForPersistence verifies that using
|
||||
// --state-file with an unknown platform surfaces a clear error rather than
|
||||
// silently falling back to EphemeralStore.
|
||||
func TestRunWithIO_UnsupportedPlatformForPersistence(t *testing.T) {
|
||||
resetFlags(t)
|
||||
t.Cleanup(func() { resetFlags(t) })
|
||||
|
||||
if err := pflag.Set("state-file", filepath.Join(t.TempDir(), "state.json")); err != nil {
|
||||
t.Fatalf("set state-file flag: %v", err)
|
||||
}
|
||||
// Currently only trading212 is wired through buildStore, but the
|
||||
// reader switch also only supports trading212, so the reader error
|
||||
// fires first. Either error is acceptable; we just need a clear
|
||||
// failure message.
|
||||
if err := pflag.Set("platform", "unknown-broker"); err != nil {
|
||||
t.Fatalf("set platform flag: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := runWithStdin(t, trading212SampleCSV, &stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error for unsupported platform")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "platform") {
|
||||
t.Errorf("expected error to mention platform, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package internal
|
||||
|
||||
// EphemeralStore loads an empty state and discards everything on save
|
||||
import "context"
|
||||
|
||||
// EphemeralStore loads an empty state and discards everything on save.
|
||||
type EphemeralStore struct{}
|
||||
|
||||
func (EphemeralStore) Load() (map[string]*FillerQueue, error) {
|
||||
func (EphemeralStore) Load(context.Context) (map[string]*FillerQueue, error) {
|
||||
return make(map[string]*FillerQueue), nil
|
||||
}
|
||||
|
||||
func (EphemeralStore) Save(map[string]*FillerQueue) error {
|
||||
func (EphemeralStore) Save(context.Context, map[string]*FillerQueue) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
)
|
||||
|
||||
// Verify that EphemeralStore.Load returns a non-nil empty map and no error.
|
||||
func TestEphemeralStore_Load(t *testing.T) {
|
||||
store := internal.EphemeralStore{}
|
||||
|
||||
queues, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error: %v", err)
|
||||
}
|
||||
if queues == nil {
|
||||
t.Fatalf("Load returned a nil map; expected an empty map")
|
||||
}
|
||||
if len(queues) != 0 {
|
||||
t.Fatalf("Load returned %d entries; expected 0", len(queues))
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that EphemeralStore.Save accepts a queue map without error and
|
||||
// discards its contents.
|
||||
func TestEphemeralStore_Save(t *testing.T) {
|
||||
store := internal.EphemeralStore{}
|
||||
|
||||
var q internal.FillerQueue
|
||||
|
||||
err := store.Save(t.Context(), map[string]*internal.FillerQueue{
|
||||
"TEST": &q,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Save returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Save is a no-op, so a subsequent Load must still be empty.
|
||||
queues, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load after Save returned unexpected error: %v", err)
|
||||
}
|
||||
if len(queues) != 0 {
|
||||
t.Fatalf("Load after Save returned %d entries; expected 0", len(queues))
|
||||
}
|
||||
}
|
||||
+133
-11
@@ -1,29 +1,151 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// FileStore loads and saves the state into a file
|
||||
// FileStore is a Store backed by a single JSON file on disk. The on-disk
|
||||
// schema is the platform-agnostic State struct; per-broker Record data is
|
||||
// encoded and decoded through the supplied RecordSerializer.
|
||||
type FileStore struct {
|
||||
file *os.File
|
||||
filename string
|
||||
platform string
|
||||
serializer RecordSerializer
|
||||
}
|
||||
|
||||
func NewFileStore(filename string) (*FileStore, error) {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// NewFileStore constructs a FileStore that reads from and writes to the given
|
||||
// filename. The FileStore does not keep an *os.File open — Load and Save each
|
||||
// open the file themselves — so callers do not need to close it. The platform
|
||||
// string is recorded into the saved state and validated on Load so a state
|
||||
// file from a different broker cannot be loaded by mistake.
|
||||
func NewFileStore(filename string, platform string, serializer RecordSerializer) (*FileStore, error) {
|
||||
if filename == "" {
|
||||
return nil, fmt.Errorf("filename cannot be empty")
|
||||
}
|
||||
if serializer == nil {
|
||||
return nil, fmt.Errorf("serializer cannot be nil")
|
||||
}
|
||||
return &FileStore{
|
||||
file: f,
|
||||
filename: filename,
|
||||
platform: platform,
|
||||
serializer: serializer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fs *FileStore) Load() (map[string]*FillerQueue, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
// Load reads the state file and reconstructs the per-symbol FillerQueue map.
|
||||
// A missing file is not an error — it returns an empty map so the first run
|
||||
// against a new state file Just Works.
|
||||
func (fs *FileStore) Load(ctx context.Context) (map[string]*FillerQueue, error) {
|
||||
data, err := os.ReadFile(fs.filename)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return make(map[string]*FillerQueue), nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading state file: %w", err)
|
||||
}
|
||||
|
||||
var s State
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("unmarshalling state: %w", err)
|
||||
}
|
||||
|
||||
if s.Version != StateVersion {
|
||||
return nil, fmt.Errorf(
|
||||
"unexpected state version %q: expected %q",
|
||||
s.Version, StateVersion,
|
||||
)
|
||||
}
|
||||
|
||||
if s.Platform != fs.platform {
|
||||
return nil, fmt.Errorf(
|
||||
"unexpected state platform %q: expected %q",
|
||||
s.Platform, fs.platform,
|
||||
)
|
||||
}
|
||||
|
||||
queues := make(map[string]*FillerQueue)
|
||||
for symbol, persisted := range s.Queues {
|
||||
q := new(FillerQueue)
|
||||
for _, pf := range persisted {
|
||||
rec, err := fs.serializer.UnmarshalRecord(ctx, pf.ReaderData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"unmarshalling record for symbol %q: %w", symbol, err,
|
||||
)
|
||||
}
|
||||
q.Push(NewFillerFromState(rec, pf.Quantity, pf.Price, pf.Filled))
|
||||
}
|
||||
queues[symbol] = q
|
||||
}
|
||||
return queues, nil
|
||||
}
|
||||
|
||||
func (fs *FileStore) Save(map[string]*FillerQueue) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
// Save serialises the queue map to disk. The write is done via a temp file in
|
||||
// the same directory followed by an atomic rename so a crash mid-write cannot
|
||||
// leave a half-written state file.
|
||||
func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) error {
|
||||
state := State{
|
||||
Version: StateVersion,
|
||||
Platform: fs.platform,
|
||||
Queues: make(map[string][]persistedFiller),
|
||||
}
|
||||
|
||||
for symbol, q := range queue {
|
||||
if q == nil {
|
||||
continue
|
||||
}
|
||||
var persisted []persistedFiller
|
||||
for e := q.l.Front(); e != nil; e = e.Next() {
|
||||
f := e.Value.(*Filler)
|
||||
data, err := fs.serializer.MarshalRecord(ctx, f.Record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshalling record for symbol %q: %w", symbol, err)
|
||||
}
|
||||
persisted = append(persisted, persistedFiller{
|
||||
ReaderData: data,
|
||||
Quantity: f.Quantity(),
|
||||
Price: f.Price(),
|
||||
Filled: f.Filled(),
|
||||
})
|
||||
}
|
||||
state.Queues[symbol] = persisted
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(state); err != nil {
|
||||
return fmt.Errorf("encoding state: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fs.filename)
|
||||
tmp, err := os.CreateTemp(dir, ".state-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp file: %w", err)
|
||||
}
|
||||
|
||||
tmpName := tmp.Name()
|
||||
// Best-effort cleanup if rename never runs (e.g. process killed between
|
||||
// CreateTemp and Rename). On success the temp file has been consumed by
|
||||
// Rename and Remove returns ENOENT which we ignore.
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := io.Copy(tmp, buf); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writing temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("closing temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, fs.filename); err != nil {
|
||||
return fmt.Errorf("renaming temp file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/mocks"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func TestFileStore_RoundTrip(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, _ := newStore(t, "fake", ser)
|
||||
|
||||
original := map[string]*internal.FillerQueue{
|
||||
"AAA": newQueue(
|
||||
newFiller(ctrl, "AAA", 100, 50, 0),
|
||||
newFiller(ctrl, "AAA", 25, 80, 5),
|
||||
),
|
||||
"BBB": newQueue(
|
||||
newFiller(ctrl, "BBB", 7, 1000, 7),
|
||||
),
|
||||
}
|
||||
|
||||
if err := store.Save(t.Context(), original); err != nil {
|
||||
t.Fatalf("Save returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(loaded) != len(original) {
|
||||
t.Fatalf("want %d symbols but got %d", len(original), len(loaded))
|
||||
}
|
||||
for symbol, wantQ := range original {
|
||||
gotQ, ok := loaded[symbol]
|
||||
if !ok {
|
||||
t.Fatalf("symbol %q missing from loaded state", symbol)
|
||||
}
|
||||
assertQueueEqual(t, symbol, gotQ, wantQ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_LoadMissingFileReturnsEmpty(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, path := newStore(t, "fake", ser)
|
||||
|
||||
// Sanity: file doesn't exist.
|
||||
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("expected state file to be absent, got stat err: %v", err)
|
||||
}
|
||||
|
||||
queues, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error for missing file: %v", err)
|
||||
}
|
||||
if queues == nil {
|
||||
t.Fatalf("Load returned nil map; want empty map")
|
||||
}
|
||||
if len(queues) != 0 {
|
||||
t.Fatalf("Load returned %d entries; want 0", len(queues))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_LoadVersionMismatch(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, path := newStore(t, "fake", ser)
|
||||
|
||||
// Hand-craft an unsupported-version state file.
|
||||
bad := struct {
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform"`
|
||||
Queues map[string][]json.RawMessage `json:"queues"`
|
||||
}{
|
||||
Version: "999",
|
||||
Platform: "fake",
|
||||
Queues: map[string][]json.RawMessage{},
|
||||
}
|
||||
data, err := json.MarshalIndent(bad, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write state: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.Load(t.Context())
|
||||
if err == nil {
|
||||
t.Fatalf("Load with bad version should return an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unexpected state version "999"`) {
|
||||
t.Errorf("expected version-mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_LoadPlatformMismatch(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, path := newStore(t, "fake", ser)
|
||||
|
||||
// File claims a different platform.
|
||||
bad := struct {
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform"`
|
||||
Queues map[string][]json.RawMessage `json:"queues"`
|
||||
}{
|
||||
Version: internal.StateVersion,
|
||||
Platform: "other-broker",
|
||||
Queues: map[string][]json.RawMessage{},
|
||||
}
|
||||
data, err := json.MarshalIndent(bad, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write state: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.Load(t.Context())
|
||||
if err == nil {
|
||||
t.Fatalf("Load with mismatched platform should return an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unexpected state platform "other-broker"`) {
|
||||
t.Errorf("expected platform-mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_SplitAdjustedLotSurvivesRoundTrip(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, _ := newStore(t, "fake", ser)
|
||||
|
||||
// Simulate a lot that has been through a 5:1 split and is partially
|
||||
// filled. Starting from 10 shares @ $100, after a 5:1 split we should
|
||||
// have 50 shares @ $20 with 20 already filled.
|
||||
f := newFiller(ctrl, "SPLIT", 10, 100, 0)
|
||||
f.ApplySplit(decimal.NewFromInt(5))
|
||||
f.Fill(decimal.NewFromInt(20))
|
||||
|
||||
q := newQueue(f)
|
||||
|
||||
if err := store.Save(t.Context(), map[string]*internal.FillerQueue{"SPLIT": q}); err != nil {
|
||||
t.Fatalf("Save returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error: %v", err)
|
||||
}
|
||||
gotQ := loaded["SPLIT"]
|
||||
if gotQ == nil || gotQ.Len() != 1 {
|
||||
t.Fatalf("want 1 lot for SPLIT, got %d", gotQ.Len())
|
||||
}
|
||||
got, _ := gotQ.Pop()
|
||||
if !got.Quantity().Equal(decimal.NewFromInt(50)) {
|
||||
t.Errorf("want quantity 50 but got %v", got.Quantity())
|
||||
}
|
||||
if !got.Price().Equal(decimal.NewFromInt(20)) {
|
||||
t.Errorf("want price 20 but got %v", got.Price())
|
||||
}
|
||||
if !got.Filled().Equal(decimal.NewFromInt(20)) {
|
||||
t.Errorf("want filled 20 but got %v", got.Filled())
|
||||
}
|
||||
if got.IsFilled() {
|
||||
t.Errorf("want IsFilled() to be false after split-adjusted partial fill")
|
||||
}
|
||||
|
||||
// Cost basis must round-trip exactly.
|
||||
if !got.Quantity().Mul(got.Price()).Equal(decimal.NewFromInt(1000)) {
|
||||
t.Errorf("want cost basis 1000 but got %v", got.Quantity().Mul(got.Price()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_PartiallyFilledLotSurvivesRoundTrip(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := roundTripSerializer(ctrl)
|
||||
store, _ := newStore(t, "fake", ser)
|
||||
|
||||
// A non-split lot that has been partially filled.
|
||||
f := newFiller(ctrl, "PART", 100, 50, 30)
|
||||
q := newQueue(f)
|
||||
|
||||
if err := store.Save(t.Context(), map[string]*internal.FillerQueue{"PART": q}); err != nil {
|
||||
t.Fatalf("Save returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error: %v", err)
|
||||
}
|
||||
gotQ := loaded["PART"]
|
||||
if gotQ == nil || gotQ.Len() != 1 {
|
||||
t.Fatalf("want 1 lot for PART, got %d", gotQ.Len())
|
||||
}
|
||||
got, _ := gotQ.Pop()
|
||||
if !got.Quantity().Equal(decimal.NewFromInt(100)) {
|
||||
t.Errorf("want quantity 100 but got %v", got.Quantity())
|
||||
}
|
||||
if !got.Price().Equal(decimal.NewFromInt(50)) {
|
||||
t.Errorf("want price 50 but got %v", got.Price())
|
||||
}
|
||||
if !got.Filled().Equal(decimal.NewFromInt(30)) {
|
||||
t.Errorf("want filled 30 but got %v", got.Filled())
|
||||
}
|
||||
if got.IsFilled() {
|
||||
t.Errorf("want IsFilled() to be false (30/100 filled)")
|
||||
}
|
||||
// Filling the remaining 70 must now make it filled.
|
||||
_, done := got.Fill(decimal.NewFromInt(70))
|
||||
if !done {
|
||||
t.Errorf("after filling remaining 70, IsFilled() should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFileStore_ValidatesArguments(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
ser := mocks.NewMockRecordSerializer(ctrl)
|
||||
|
||||
if _, err := internal.NewFileStore("", "fake", ser); err == nil {
|
||||
t.Errorf("NewFileStore with empty filename should fail")
|
||||
}
|
||||
if _, err := internal.NewFileStore("/tmp/x", "fake", nil); err == nil {
|
||||
t.Errorf("NewFileStore with nil serializer should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// newRecord builds a MockRecord whose Symbol() returns the given symbol.
|
||||
// The FileStore only reads Symbol() off the loaded Record during tests
|
||||
// (quantity/price/filled come from the persisted struct fields), so all
|
||||
// other Record methods can be left as default-mocked values.
|
||||
func newRecord(ctrl *gomock.Controller, symbol string) *mocks.MockRecord {
|
||||
r := mocks.NewMockRecord(ctrl)
|
||||
r.EXPECT().Symbol().Return(symbol).AnyTimes()
|
||||
return r
|
||||
}
|
||||
|
||||
// newFiller builds a Filler backed by a MockRecord with the given symbol,
|
||||
// quantity, price, and filled amounts (in whole units). It mirrors the
|
||||
// inline calls that previously littered every test.
|
||||
func newFiller(ctrl *gomock.Controller, symbol string, quantity, price, filled int64) *internal.Filler {
|
||||
return internal.NewFillerFromState(
|
||||
newRecord(ctrl, symbol),
|
||||
decimal.NewFromInt(quantity),
|
||||
decimal.NewFromInt(price),
|
||||
decimal.NewFromInt(filled),
|
||||
)
|
||||
}
|
||||
|
||||
// newQueue creates a FillerQueue pre-populated with the given fillers.
|
||||
func newQueue(fillers ...*internal.Filler) *internal.FillerQueue {
|
||||
q := new(internal.FillerQueue)
|
||||
for _, f := range fillers {
|
||||
q.Push(f)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// newStore creates a FileStore backed by a temp file and returns the store
|
||||
// plus the path to the underlying state file.
|
||||
func newStore(t *testing.T, platform string, ser internal.RecordSerializer) (*internal.FileStore, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
store, err := internal.NewFileStore(path, platform, ser)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore returned unexpected error: %v", err)
|
||||
}
|
||||
return store, path
|
||||
}
|
||||
|
||||
// roundTripSerializer returns a serializer mock whose MarshalRecord encodes
|
||||
// the Symbol into bytes and whose UnmarshalRecord decodes those bytes back
|
||||
// into a fresh MockRecord returning the encoded Symbol. Tests use this when
|
||||
// they need Save + Load to round-trip equivalent Records.
|
||||
func roundTripSerializer(ctrl *gomock.Controller) *mocks.MockRecordSerializer {
|
||||
ser := mocks.NewMockRecordSerializer(ctrl)
|
||||
ser.EXPECT().
|
||||
MarshalRecord(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, r internal.Record) ([]byte, error) {
|
||||
return []byte(r.Symbol()), nil
|
||||
}).
|
||||
AnyTimes()
|
||||
ser.EXPECT().
|
||||
UnmarshalRecord(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, b []byte) (internal.Record, error) {
|
||||
return newRecord(ctrl, string(b)), nil
|
||||
}).
|
||||
AnyTimes()
|
||||
return ser
|
||||
}
|
||||
|
||||
// queueSnapshot drains the given FillerQueue (via Pop) and returns its
|
||||
// contents in a side-effect-free shape suitable for value comparison.
|
||||
// Callers should not use the queue afterwards.
|
||||
func queueSnapshot(q *internal.FillerQueue) []queueEntry {
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
var out []queueEntry
|
||||
for {
|
||||
f, ok := q.Pop()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
out = append(out, queueEntry{
|
||||
symbol: f.Symbol(),
|
||||
quantity: f.Quantity(),
|
||||
price: f.Price(),
|
||||
filled: f.Filled(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// assertQueueEqual compares two FillerQueues by popping every element from
|
||||
// each and comparing the resulting sequence of queueEntries. Both queues are
|
||||
// drained as a side effect.
|
||||
func assertQueueEqual(t *testing.T, symbol string, got, want *internal.FillerQueue) {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
t.Fatalf("symbol %q: loaded queue is nil", symbol)
|
||||
}
|
||||
if got.Len() != want.Len() {
|
||||
t.Fatalf("symbol %q: want %d lots but got %d", symbol, want.Len(), got.Len())
|
||||
}
|
||||
wantEntries := queueSnapshot(want)
|
||||
gotEntries := queueSnapshot(got)
|
||||
for i := range wantEntries {
|
||||
w := wantEntries[i]
|
||||
g := gotEntries[i]
|
||||
if w.symbol != g.symbol {
|
||||
t.Errorf("symbol %q lot %d: want symbol %q but got %q",
|
||||
symbol, i, w.symbol, g.symbol)
|
||||
}
|
||||
if !w.quantity.Equal(g.quantity) {
|
||||
t.Errorf("symbol %q lot %d: want quantity %v but got %v",
|
||||
symbol, i, w.quantity, g.quantity)
|
||||
}
|
||||
if !w.price.Equal(g.price) {
|
||||
t.Errorf("symbol %q lot %d: want price %v but got %v",
|
||||
symbol, i, w.price, g.price)
|
||||
}
|
||||
if !w.filled.Equal(g.filled) {
|
||||
t.Errorf("symbol %q lot %d: want filled %v but got %v",
|
||||
symbol, i, w.filled, g.filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type queueEntry struct {
|
||||
symbol string
|
||||
quantity decimal.Decimal
|
||||
price decimal.Decimal
|
||||
filled decimal.Decimal
|
||||
}
|
||||
+18
-2
@@ -22,10 +22,26 @@ func NewFiller(r Record) *Filler {
|
||||
}
|
||||
}
|
||||
|
||||
// NewFillerFromState constructs a Filler from a previously persisted state.
|
||||
// It bypasses the Record-derived defaults so callers can restore a lot that
|
||||
// may have been split-adjusted or partially filled since the Record was first
|
||||
// created.
|
||||
func NewFillerFromState(record Record, quantity, price, filled decimal.Decimal) *Filler {
|
||||
return &Filler{
|
||||
Record: record,
|
||||
filled: filled,
|
||||
quantity: quantity,
|
||||
price: price,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Filler) Quantity() decimal.Decimal { return f.quantity }
|
||||
|
||||
func (f *Filler) Price() decimal.Decimal { return f.price }
|
||||
|
||||
// Filled returns how much of the Filler's quantity has already been consumed.
|
||||
func (f *Filler) Filled() decimal.Decimal { return f.filled }
|
||||
|
||||
// Fill accrues some quantity. Returns how mutch was accrued in the 1st return value and whether
|
||||
// it was filled or not on the 2nd return value.
|
||||
func (f *Filler) Fill(quantity decimal.Decimal) (decimal.Decimal, bool) {
|
||||
@@ -70,8 +86,8 @@ func (fq *FillerQueue) Push(f *Filler) {
|
||||
fq.l.PushBack(f)
|
||||
}
|
||||
|
||||
// Pop removes and returns the first Filler of the queue in the 1st return value. If the list is
|
||||
// empty returns false on the 2nd return value, true otherwise.
|
||||
// Pop removes and returns the first Filler of the queue in the 1st return value if there is one. If
|
||||
// the queue is already empty returns false on the 2nd return value, otherwise returns true.
|
||||
func (fq *FillerQueue) Pop() (*Filler, bool) {
|
||||
el := fq.frontElement()
|
||||
if el == nil {
|
||||
|
||||
@@ -271,3 +271,47 @@ func TestFillerQueue_AdjustForSplit_NilReceiver(t *testing.T) {
|
||||
var fq *FillerQueue
|
||||
fq.AdjustForSplit(decimal.NewFromFloat(5)) // must not panic
|
||||
}
|
||||
|
||||
func TestNewFillerFromState(t *testing.T) {
|
||||
// The underlying Record reports very different values; NewFillerFromState
|
||||
// must ignore them and use the caller-supplied overrides instead.
|
||||
rec := &testRecord{
|
||||
quantity: decimal.NewFromFloat(999),
|
||||
price: decimal.NewFromFloat(999),
|
||||
}
|
||||
|
||||
qty := decimal.NewFromFloat(50)
|
||||
price := decimal.NewFromFloat(20)
|
||||
filled := qty // fully filled on load
|
||||
|
||||
f := NewFillerFromState(rec, qty, price, filled)
|
||||
|
||||
if !f.Quantity().Equal(qty) {
|
||||
t.Errorf("want quantity %v but got %v", qty, f.Quantity())
|
||||
}
|
||||
if !f.Price().Equal(price) {
|
||||
t.Errorf("want price %v but got %v", price, f.Price())
|
||||
}
|
||||
if !f.IsFilled() {
|
||||
t.Errorf("want IsFilled() to be true when filled == quantity")
|
||||
}
|
||||
|
||||
// Partially filled: filled < quantity => IsFilled must be false.
|
||||
partial := NewFillerFromState(rec, qty, price, decimal.NewFromFloat(10))
|
||||
if partial.IsFilled() {
|
||||
t.Errorf("want IsFilled() to be false when filled < quantity")
|
||||
}
|
||||
if !partial.Quantity().Equal(qty) {
|
||||
t.Errorf("want quantity %v but got %v", qty, partial.Quantity())
|
||||
}
|
||||
if !partial.Price().Equal(price) {
|
||||
t.Errorf("want price %v but got %v", price, partial.Price())
|
||||
}
|
||||
|
||||
// Filled from Fill() on a restored lot must work correctly against the
|
||||
// restored (not Record-derived) quantity/price.
|
||||
_, done := partial.Fill(decimal.NewFromFloat(40))
|
||||
if !done {
|
||||
t.Errorf("after filling the remaining 40, IsFilled() should be true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package internal
|
||||
|
||||
//go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
|
||||
//go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer
|
||||
|
||||
+230
-2
@@ -1,9 +1,9 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter)
|
||||
// Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
|
||||
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
@@ -547,3 +547,231 @@ func (c *MockReportWriterWriteCall) DoAndReturn(f func(context.Context, internal
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// MockRecordEncoder is a mock of RecordEncoder interface.
|
||||
type MockRecordEncoder struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRecordEncoderMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRecordEncoderMockRecorder is the mock recorder for MockRecordEncoder.
|
||||
type MockRecordEncoderMockRecorder struct {
|
||||
mock *MockRecordEncoder
|
||||
}
|
||||
|
||||
// NewMockRecordEncoder creates a new mock instance.
|
||||
func NewMockRecordEncoder(ctrl *gomock.Controller) *MockRecordEncoder {
|
||||
mock := &MockRecordEncoder{ctrl: ctrl}
|
||||
mock.recorder = &MockRecordEncoderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRecordEncoder) EXPECT() *MockRecordEncoderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// MarshalRecord mocks base method.
|
||||
func (m *MockRecordEncoder) MarshalRecord(arg0 context.Context, arg1 internal.Record) ([]byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].([]byte)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// MarshalRecord indicates an expected call of MarshalRecord.
|
||||
func (mr *MockRecordEncoderMockRecorder) MarshalRecord(arg0, arg1 any) *MockRecordEncoderMarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarshalRecord", reflect.TypeOf((*MockRecordEncoder)(nil).MarshalRecord), arg0, arg1)
|
||||
return &MockRecordEncoderMarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordEncoderMarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordEncoderMarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordEncoderMarshalRecordCall) Return(arg0 []byte, arg1 error) *MockRecordEncoderMarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordEncoderMarshalRecordCall) Do(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordEncoderMarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordEncoderMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordEncoderMarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// MockRecordDecoder is a mock of RecordDecoder interface.
|
||||
type MockRecordDecoder struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRecordDecoderMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRecordDecoderMockRecorder is the mock recorder for MockRecordDecoder.
|
||||
type MockRecordDecoderMockRecorder struct {
|
||||
mock *MockRecordDecoder
|
||||
}
|
||||
|
||||
// NewMockRecordDecoder creates a new mock instance.
|
||||
func NewMockRecordDecoder(ctrl *gomock.Controller) *MockRecordDecoder {
|
||||
mock := &MockRecordDecoder{ctrl: ctrl}
|
||||
mock.recorder = &MockRecordDecoderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRecordDecoder) EXPECT() *MockRecordDecoderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// UnmarshalRecord mocks base method.
|
||||
func (m *MockRecordDecoder) UnmarshalRecord(arg0 context.Context, arg1 []byte) (internal.Record, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UnmarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].(internal.Record)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UnmarshalRecord indicates an expected call of UnmarshalRecord.
|
||||
func (mr *MockRecordDecoderMockRecorder) UnmarshalRecord(arg0, arg1 any) *MockRecordDecoderUnmarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnmarshalRecord", reflect.TypeOf((*MockRecordDecoder)(nil).UnmarshalRecord), arg0, arg1)
|
||||
return &MockRecordDecoderUnmarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordDecoderUnmarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordDecoderUnmarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordDecoderUnmarshalRecordCall) Return(arg0 internal.Record, arg1 error) *MockRecordDecoderUnmarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordDecoderUnmarshalRecordCall) Do(f func(context.Context, []byte) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordDecoderUnmarshalRecordCall) DoAndReturn(f func(context.Context, []byte) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// MockRecordSerializer is a mock of RecordSerializer interface.
|
||||
type MockRecordSerializer struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRecordSerializerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRecordSerializerMockRecorder is the mock recorder for MockRecordSerializer.
|
||||
type MockRecordSerializerMockRecorder struct {
|
||||
mock *MockRecordSerializer
|
||||
}
|
||||
|
||||
// NewMockRecordSerializer creates a new mock instance.
|
||||
func NewMockRecordSerializer(ctrl *gomock.Controller) *MockRecordSerializer {
|
||||
mock := &MockRecordSerializer{ctrl: ctrl}
|
||||
mock.recorder = &MockRecordSerializerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRecordSerializer) EXPECT() *MockRecordSerializerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// MarshalRecord mocks base method.
|
||||
func (m *MockRecordSerializer) MarshalRecord(arg0 context.Context, arg1 internal.Record) ([]byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].([]byte)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// MarshalRecord indicates an expected call of MarshalRecord.
|
||||
func (mr *MockRecordSerializerMockRecorder) MarshalRecord(arg0, arg1 any) *MockRecordSerializerMarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarshalRecord", reflect.TypeOf((*MockRecordSerializer)(nil).MarshalRecord), arg0, arg1)
|
||||
return &MockRecordSerializerMarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordSerializerMarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordSerializerMarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordSerializerMarshalRecordCall) Return(arg0 []byte, arg1 error) *MockRecordSerializerMarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordSerializerMarshalRecordCall) Do(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordSerializerMarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordSerializerMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordSerializerMarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// UnmarshalRecord mocks base method.
|
||||
func (m *MockRecordSerializer) UnmarshalRecord(arg0 context.Context, arg1 []byte) (internal.Record, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UnmarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].(internal.Record)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UnmarshalRecord indicates an expected call of UnmarshalRecord.
|
||||
func (mr *MockRecordSerializerMockRecorder) UnmarshalRecord(arg0, arg1 any) *MockRecordSerializerUnmarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnmarshalRecord", reflect.TypeOf((*MockRecordSerializer)(nil).UnmarshalRecord), arg0, arg1)
|
||||
return &MockRecordSerializerUnmarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordSerializerUnmarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordSerializerUnmarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordSerializerUnmarshalRecordCall) Return(arg0 internal.Record, arg1 error) *MockRecordSerializerUnmarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordSerializerUnmarshalRecordCall) Do(f func(context.Context, []byte) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordSerializerUnmarshalRecordCall) DoAndReturn(f func(context.Context, []byte) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
+4
-10
@@ -75,8 +75,8 @@ func WithSelector(s Selector) Option {
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Load() (map[string]*FillerQueue, error)
|
||||
Save(map[string]*FillerQueue) error
|
||||
Load(context.Context) (map[string]*FillerQueue, error)
|
||||
Save(context.Context, map[string]*FillerQueue) error
|
||||
}
|
||||
|
||||
func WithStore(s Store) Option {
|
||||
@@ -93,7 +93,7 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter,
|
||||
store: EphemeralStore{},
|
||||
}, options)
|
||||
|
||||
buys, err := optionals.store.Load()
|
||||
buys, err := optionals.store.Load(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading state: %w", err)
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter,
|
||||
rec, err := reader.ReadRecord(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
err = optionals.store.Save(buys)
|
||||
err = optionals.store.Save(ctx, buys)
|
||||
if err != nil {
|
||||
return fmt.Errorf("saving state: %w", err)
|
||||
}
|
||||
@@ -182,12 +182,6 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector
|
||||
return ErrInsufficientBoughtVolume
|
||||
}
|
||||
|
||||
// Since we don't apply selectors while processing buys we need to apply them here, before we
|
||||
// actually use them.
|
||||
if !sel(buy) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchedQty, filled := buy.Fill(unmatchedQty)
|
||||
|
||||
if filled {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -98,3 +99,87 @@ func (m ReportItemMatcher) String() string {
|
||||
}
|
||||
|
||||
var _ gomock.Matcher = (*ReportItemMatcher)(nil)
|
||||
|
||||
// recordingStore is a test fake for internal.Store that records how many
|
||||
// times Load/Save were invoked and captures the maps it handed back so a
|
||||
// test can verify BuildReport wires the store correctly.
|
||||
type recordingStore struct {
|
||||
loadCalls int
|
||||
saveCalls int
|
||||
loaded map[string]*internal.FillerQueue
|
||||
saved map[string]*internal.FillerQueue
|
||||
}
|
||||
|
||||
func (s *recordingStore) Load(_ context.Context) (map[string]*internal.FillerQueue, error) {
|
||||
s.loadCalls++
|
||||
if s.loaded == nil {
|
||||
s.loaded = map[string]*internal.FillerQueue{}
|
||||
}
|
||||
return s.loaded, nil
|
||||
}
|
||||
|
||||
func (s *recordingStore) Save(_ context.Context, queues map[string]*internal.FillerQueue) error {
|
||||
s.saveCalls++
|
||||
s.saved = queues
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBuildReport_WithStore(t *testing.T) {
|
||||
now := time.Now()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
reader := mocks.NewMockRecordReader(ctrl)
|
||||
records := []internal.Record{
|
||||
mockRecord(ctrl, 20.0, 10.0, internal.KindBuy, now),
|
||||
}
|
||||
reader.EXPECT().ReadRecord(gomock.Any()).DoAndReturn(func(ctx context.Context) (internal.Record, error) {
|
||||
if len(records) > 0 {
|
||||
r := records[0]
|
||||
records = records[1:]
|
||||
return r, nil
|
||||
}
|
||||
return nil, io.EOF
|
||||
}).Times(2)
|
||||
|
||||
// No sells, so the writer must not be called.
|
||||
writer := mocks.NewMockReportWriter(ctrl)
|
||||
|
||||
store := &recordingStore{}
|
||||
|
||||
gotErr := internal.BuildReport(t.Context(), reader, writer, internal.WithStore(store))
|
||||
if gotErr != nil {
|
||||
t.Fatalf("got unexpected err: %v", gotErr)
|
||||
}
|
||||
|
||||
if store.loadCalls != 1 {
|
||||
t.Errorf("Load calls: want 1 but got %d", store.loadCalls)
|
||||
}
|
||||
if store.saveCalls != 1 {
|
||||
t.Errorf("Save calls: want 1 but got %d", store.saveCalls)
|
||||
}
|
||||
|
||||
// Load must run before any records are read, so it has already been
|
||||
// invoked by the time Save is observed.
|
||||
if store.loadCalls < store.saveCalls {
|
||||
t.Errorf("Load (%d) should be called at least as many times as Save (%d)", store.loadCalls, store.saveCalls)
|
||||
}
|
||||
|
||||
if store.loaded == nil {
|
||||
t.Fatalf("Load was not called or returned a nil map")
|
||||
}
|
||||
if store.saved == nil {
|
||||
t.Fatalf("Save was not called or received a nil map")
|
||||
}
|
||||
|
||||
// The map handed to Save must be the same map returned by Load so
|
||||
// that buy-queue mutations done while processing flow into Save.
|
||||
if reflect.ValueOf(store.loaded).Pointer() != reflect.ValueOf(store.saved).Pointer() {
|
||||
t.Errorf("map passed to Save is not the same map returned by Load")
|
||||
}
|
||||
|
||||
// Sanity: the buy record above should have populated an entry for the
|
||||
// "TEST" symbol in the shared map.
|
||||
if _, ok := store.saved["TEST"]; !ok {
|
||||
t.Errorf("want saved map to contain symbol %q but it was missing", "TEST")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// StateVersion is the schema version of the persisted State struct.
|
||||
const StateVersion = "1"
|
||||
|
||||
// State is the platform-agnostic representation of the buy-queue state that is
|
||||
// written to disk after a successful run and reloaded on the next run.
|
||||
type State struct {
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform"`
|
||||
Queues map[string][]persistedFiller `json:"queues"`
|
||||
}
|
||||
|
||||
// persistedFiller is the on-disk representation of a single Filler. The
|
||||
// Record-specific data is kept as opaque bytes so the internal package does
|
||||
// not need to know about any broker package's concrete Record type.
|
||||
type persistedFiller struct {
|
||||
ReaderData []byte `json:"reader_data"`
|
||||
Quantity decimal.Decimal `json:"quantity"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
Filled decimal.Decimal `json:"filled"`
|
||||
}
|
||||
|
||||
// RecordEncoder encodes a Record into its broker-specific byte representation.
|
||||
type RecordEncoder interface {
|
||||
MarshalRecord(context.Context, Record) ([]byte, error)
|
||||
}
|
||||
|
||||
// RecordDecoder decodes broker-specific bytes back into a Record.
|
||||
type RecordDecoder interface {
|
||||
UnmarshalRecord(context.Context, []byte) (Record, error)
|
||||
}
|
||||
|
||||
// RecordSerializer composes encoding and decoding of Records. Broker packages
|
||||
// own the implementation so that lazy/closured Record fields can be re-wired
|
||||
// on load.
|
||||
type RecordSerializer interface {
|
||||
RecordEncoder
|
||||
RecordDecoder
|
||||
}
|
||||
@@ -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