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
+59 -19
View File
@@ -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)
}
+168
View File
@@ -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)
}
}
+5 -3
View File
@@ -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
}
+47
View File
@@ -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
View File
@@ -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
}
+368
View File
@@ -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
View File
@@ -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 {
+44
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+85
View File
@@ -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")
}
}
+46
View File
@@ -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
}
+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 }