diff --git a/.gitea/workflows/claude.yml b/.gitea/workflows/claude.yml deleted file mode 100644 index 70f1fac..0000000 --- a/.gitea/workflows/claude.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Claude Assistant - -on: - # Trigger on issue comments (works on both issues and pull requests in Gitea) - issue_comment: - types: [created] - # Trigger on issues being opened or assigned - issues: - types: [opened, assigned] - # Note: pull_request_review_comment has limited support in Gitea - # Use issue_comment instead which covers PR comments - -jobs: - claude-assistant: - # Basic trigger detection - check for @claude in comments or issue body - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || github.event.action == 'assigned')) - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - issues: write - # Note: Gitea Actions may not require id-token: write for basic functionality - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Run Claude Assistant - uses: markwylde/claude-code-gitea-action@v1.0.20 - with: - gitea_token: ${{ secrets.GITEA_TOKEN }} # Use standard workflow token - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - timeout_minutes: "60" - trigger_phrase: "@claude" - # Optional: Customize for Gitea environment - custom_instructions: | - You are working in a Gitea environment. Be aware that: - - Some GitHub Actions features may behave differently - - Focus on core functionality and avoid advanced GitHub-specific features - - Use standard git operations when possible diff --git a/README.md b/README.md index fff8cc2..fea41d5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # any2anexoj -[![Go Report Card](https://goreportcard.com/badge/github.com/nmoniz/any2anexoj)](https://goreportcard.com/report/github.com/nmoniz/any2anexoj) [![Coverage Status](https://coveralls.io/repos/github/nmoniz/any2anexoj/badge.svg?branch=main)](https://coveralls.io/github/nmoniz/any2anexoj?branch=main)

diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index ae0fb89..6caae5b 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "io" "log/slog" "net/http" "os" @@ -10,22 +11,22 @@ import ( "time" "github.com/nmoniz/any2anexoj/internal" + "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() { @@ -38,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 @@ -58,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) @@ -70,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, 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, internal.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) } diff --git a/cmd/any2anexoj-cli/main_test.go b/cmd/any2anexoj-cli/main_test.go new file mode 100644 index 0000000..81d6517 --- /dev/null +++ b/cmd/any2anexoj-cli/main_test.go @@ -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) + } +} diff --git a/cmd/any2anexoj-cli/pretty_printer_test.go b/cmd/any2anexoj-cli/pretty_printer_test.go index 48cb3ca..72db220 100644 --- a/cmd/any2anexoj-cli/pretty_printer_test.go +++ b/cmd/any2anexoj-cli/pretty_printer_test.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "context" "testing" "time" @@ -13,7 +12,7 @@ import ( func TestPrettyPrinter_Render(t *testing.T) { // Create test data aw := internal.NewAggregatorWriter() - ctx := context.Background() + ctx := t.Context() // Add some sample report items err := aw.Write(ctx, internal.ReportItem{ diff --git a/internal/aggregator_writer_test.go b/internal/aggregator_writer_test.go index aab8dc4..9936b55 100644 --- a/internal/aggregator_writer_test.go +++ b/internal/aggregator_writer_test.go @@ -1,7 +1,6 @@ package internal_test import ( - "context" "sync" "testing" "time" @@ -91,7 +90,7 @@ func TestAggregatorWriter_Write(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { aw := &internal.AggregatorWriter{} - ctx := context.Background() + ctx := t.Context() for _, item := range tt.items { if err := aw.Write(ctx, item); err != nil { @@ -191,7 +190,7 @@ func TestAggregatorWriter_Rounding(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { aw := &internal.AggregatorWriter{} - ctx := context.Background() + ctx := t.Context() for _, item := range tt.items { if err := aw.Write(ctx, item); err != nil { @@ -209,7 +208,7 @@ func TestAggregatorWriter_Rounding(t *testing.T) { func TestAggregatorWriter_Items(t *testing.T) { aw := &internal.AggregatorWriter{} - ctx := context.Background() + ctx := t.Context() for range 5 { item := internal.ReportItem{Symbol: "TEST"} @@ -241,7 +240,7 @@ func TestAggregatorWriter_Items(t *testing.T) { func TestAggregatorWriter_ThreadSafety(t *testing.T) { aw := &internal.AggregatorWriter{} - ctx := context.Background() + ctx := t.Context() numGoroutines := 100 writesPerGoroutine := 100 diff --git a/internal/ephemeral_store.go b/internal/ephemeral_store.go new file mode 100644 index 0000000..e1338e2 --- /dev/null +++ b/internal/ephemeral_store.go @@ -0,0 +1,14 @@ +package internal + +import "context" + +// EphemeralStore loads an empty state and discards everything on save. +type EphemeralStore struct{} + +func (EphemeralStore) Load(context.Context) (map[string]*FillerQueue, error) { + return make(map[string]*FillerQueue), nil +} + +func (EphemeralStore) Save(context.Context, map[string]*FillerQueue) error { + return nil +} diff --git a/internal/ephemeral_store_test.go b/internal/ephemeral_store_test.go new file mode 100644 index 0000000..fbc9561 --- /dev/null +++ b/internal/ephemeral_store_test.go @@ -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)) + } +} diff --git a/internal/file_store.go b/internal/file_store.go new file mode 100644 index 0000000..4a099ff --- /dev/null +++ b/internal/file_store.go @@ -0,0 +1,153 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// 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 { + filename string + platform string + serializer RecordSerializer +} + +// 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{ + filename: filename, + platform: platform, + serializer: serializer, + }, nil +} + +// 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) + } + if len(data) == 0 { + return make(map[string]*FillerQueue), nil + } + + 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.RecordData) + 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 +} + +// 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 || q.Len() == 0 { + 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{ + RecordData: data, + Quantity: f.Quantity(), + Price: f.Price(), + Filled: f.Filled(), + }) + } + state.Queues[symbol] = persisted + } + + ext := filepath.Ext(fs.filename) + name, _ := strings.CutSuffix(fs.filename, ext) + backupFilename := name + "." + strconv.FormatInt(time.Now().UnixMilli(), 10) + ext + err := os.Rename(fs.filename, backupFilename) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("rename old state file: %w", err) + } + } + + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetIndent("", " ") + if err := enc.Encode(state); err != nil { + return fmt.Errorf("encoding state: %w", err) + } + + dst, err := os.Create(fs.filename) + if err != nil { + return fmt.Errorf("creating new state file: %w", err) + } + + if _, err := io.Copy(dst, buf); err != nil { + return fmt.Errorf("writing to new state file: %w", err) + } + return nil +} diff --git a/internal/file_store_test.go b/internal/file_store_test.go new file mode 100644 index 0000000..b3c12c6 --- /dev/null +++ b/internal/file_store_test.go @@ -0,0 +1,435 @@ +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, path := 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) + } + + // Regression: the on-disk JSON key for the per-record blob must be + // "record_data" (renamed from "reader_data") so the field name stays + // honest about what it carries. + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read state file: %v", err) + } + if strings.Contains(string(data), `"reader_data"`) { + t.Errorf("saved state file still contains legacy key \"reader_data\"; want only \"record_data\"") + } + if !strings.Contains(string(data), `"record_data"`) { + t.Errorf("saved state file does not contain expected key \"record_data\"") + } +} + +func TestFileStore_SaveSkipsEmptyQueue(t *testing.T) { + ctrl := gomock.NewController(t) + ser := roundTripSerializer(ctrl) + store, path := newStore(t, "fake", ser) + + // A non-nil FillerQueue whose underlying list is nil — i.e. a symbol + // that was registered but never received a Push. Save must not panic + // when iterating and must not emit any entry for that symbol. + queues := map[string]*internal.FillerQueue{ + "EMPTY": new(internal.FillerQueue), + "REAL": newQueue(newFiller(ctrl, "REAL", 5, 10, 0)), + } + + if err := store.Save(t.Context(), queues); err != nil { + t.Fatalf("Save returned unexpected error: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read state file: %v", err) + } + body := string(data) + if strings.Contains(body, `"EMPTY"`) { + t.Errorf("saved state file contains entry for empty queue \"EMPTY\"; want it skipped") + } + if !strings.Contains(body, `"REAL"`) { + t.Errorf("saved state file missing expected entry for \"REAL\"") + } +} + +func TestFileStore_LoadEmptyFileReturnsEmpty(t *testing.T) { + ctrl := gomock.NewController(t) + ser := roundTripSerializer(ctrl) + store, path := newStore(t, "fake", ser) + + // Pre-create the state file as a zero-byte file. Load must treat this + // the same as a missing file rather than failing JSON unmarshal. + if err := os.WriteFile(path, []byte{}, 0o644); err != nil { + t.Fatalf("write empty state file: %v", err) + } + + queues, err := store.Load(t.Context()) + if err != nil { + t.Fatalf("Load returned unexpected error for empty 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_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 +} diff --git a/internal/filler.go b/internal/filler.go index ab1053e..4535ea1 100644 --- a/internal/filler.go +++ b/internal/filler.go @@ -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 { diff --git a/internal/filler_test.go b/internal/filler_test.go index 6b5726d..7e90c83 100644 --- a/internal/filler_test.go +++ b/internal/filler_test.go @@ -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") + } +} diff --git a/internal/generate.go b/internal/generate.go index a0a9c26..efffb8b 100644 --- a/internal/generate.go +++ b/internal/generate.go @@ -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 diff --git a/internal/kind.go b/internal/kind.go index 50c8af0..9eee9a4 100644 --- a/internal/kind.go +++ b/internal/kind.go @@ -7,11 +7,12 @@ const ( KindBuy KindSell KindSplit + sentinelKind ) -// String returns a human readable value -func (d Kind) String() string { - switch d { +// String returns a unique string value for Kind k +func (k Kind) String() string { + switch k { case KindBuy: return "buy" case KindSell: @@ -23,8 +24,13 @@ func (d Kind) String() string { } } -// Is returns true when k equals o +// Valid returns true if k is an accepted value for the Kind type +func (k Kind) Valid() bool { + return k > 0 && k < sentinelKind +} + +// Is returns true when k and o are valid and equal. func (k Kind) Is(o any) bool { other, ok := o.(Kind) - return ok && k == other + return ok && k.Valid() && k == other } diff --git a/internal/kind_test.go b/internal/kind_test.go index a52d7e6..c5af02b 100644 --- a/internal/kind_test.go +++ b/internal/kind_test.go @@ -1,60 +1,95 @@ package internal -import "testing" +import ( + "fmt" + "testing" +) func TestSide_String(t *testing.T) { - tests := []struct { - name string - side Kind - want string - }{ - {"buy", KindBuy, "buy"}, - {"sell", KindSell, "sell"}, - {"unknown", KindUnknown, "unknown"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.side.String(); got != tt.want { - t.Errorf("want Side.String() to be %v but got %v", tt.want, got) + const unknown = "unknown" + + seen := make(map[string]Kind, sentinelKind) + for k := Kind(1); k < sentinelKind; k++ { + t.Run(fmt.Sprintf("Kind %d", k), func(t *testing.T) { + str := k.String() + + if other, ok := seen[str]; ok { + t.Errorf("want Kind(%d).String to be unique but was a duplicate of Kind(%d)", k, other) + } else { + seen[str] = k + } + + if len(str) == 0 { + t.Errorf("want Kind(%d).String to be non-empty", k) + } + + if str == unknown { + t.Errorf("want Kind(%d).String to be a known value", k) } }) } + + if KindUnknown.String() != unknown { + t.Errorf("want Kind(0) to be unknown") + } + + if Kind(sentinelKind).String() != unknown { + t.Errorf("want Kind(%d) to be unknown", sentinelKind) + } } -func TestSide_IsBuy(t *testing.T) { - tests := []struct { - name string - side Kind - want bool - }{ - {"buy", KindBuy, true}, - {"sell", KindSell, false}, - {"unknown", KindUnknown, false}, +func TestSide_Valid(t *testing.T) { + for k := Kind(1); k < sentinelKind; k++ { + if !k.Valid() { + t.Errorf("want %s(%d) to be valid", k, k) + } } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.side.Is(KindBuy); got != tt.want { - t.Errorf("want Side.IsBuy() to be %v but got %v", tt.want, got) - } - }) + + if KindUnknown.Valid() { + t.Errorf("want Kind(0) to be invalid") + } + + if Kind(sentinelKind).Valid() { + t.Errorf("want Kind(%d) to be invalid", sentinelKind) } } -func TestSide_IsSell(t *testing.T) { - tests := []struct { - name string - side Kind - want bool - }{ - {"buy", KindBuy, false}, - {"sell", KindSell, true}, - {"unknown", KindUnknown, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.side.Is(KindSell); got != tt.want { - t.Errorf("want Side.IsSell() to be %v but got %v", tt.want, got) +func TestSide_Is(t *testing.T) { + t.Run("valid is self", func(t *testing.T) { + for k := Kind(1); k < sentinelKind; k++ { + if !k.Is(k) { + t.Errorf("want Kind(%d).Is(%d) to be true", k, k) } - }) - } + } + }) + + t.Run("valid is unknown", func(t *testing.T) { + for k := Kind(1); k < sentinelKind; k++ { + if k.Is(KindUnknown) { + t.Errorf("want Kind(%d).Is(0) to be false", k) + } + + if k.Is(sentinelKind) { + t.Errorf("want Kind(%d).Is(%d) to be false", k, sentinelKind) + } + + if k.Is(struct{}{}) { + t.Errorf("want Kind(%d).Is(other type) to be false", k) + } + } + }) + + t.Run("unknown is unknown", func(t *testing.T) { + if KindUnknown.Is(KindUnknown) { + t.Errorf("want Kind(0).Is(0) to be false") + } + + if KindUnknown.Is(sentinelKind) { + t.Errorf("want Kind(0).Is(%d) to be false", sentinelKind) + } + + if sentinelKind.Is(sentinelKind) { + t.Errorf("want Kind(%d).Is(%d) to be false", sentinelKind, sentinelKind) + } + }) } diff --git a/internal/mocks/mocks_gen.go b/internal/mocks/mocks_gen.go index 427c3f4..059b76d 100644 --- a/internal/mocks/mocks_gen.go +++ b/internal/mocks/mocks_gen.go @@ -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 +} diff --git a/internal/open_figi.go b/internal/ofigi/client.go similarity index 93% rename from internal/open_figi.go rename to internal/ofigi/client.go index d4bce71..b969cde 100644 --- a/internal/open_figi.go +++ b/internal/ofigi/client.go @@ -1,4 +1,4 @@ -package internal +package ofigi import ( "bytes" @@ -16,8 +16,8 @@ import ( var OpenFIGIAPIKeyHeader = http.CanonicalHeaderKey("X-OPENFIGI-APIKEY") -// OpenFIGI is a small adapter for the openfigi.com api. -type OpenFIGI struct { +// Client is a thin adapter for the openfigi.com api. +type Client struct { client *http.Client apiKey string mappingLimiter *rate.Limiter @@ -25,12 +25,13 @@ type OpenFIGI struct { mu sync.RWMutex // TODO: there's no eviction policy at the moment as this is only used by short-lived application // which processes a relatively small amount of records. We need to consider using an external - // cache lib (like golang-lru or go-cache) if this becomes a problem or implement this ourselves. + // cache lib (like golang-lru or go-cache) if this becomes a problem or implement eviction + // ourselves. securityTypeCache map[string]string } // NewOpenFIGI creates an OpenFIGI client that uses the API key if provided -func NewOpenFIGI(c *http.Client, apiKey string) *OpenFIGI { +func NewOpenFIGI(c *http.Client, apiKey string) *Client { // Rate limits as per https://www.openfigi.com/api/documentation#rate-limits limiter := rate.NewLimiter(rate.Every(time.Minute), 25) if len(apiKey) > 0 { @@ -40,7 +41,7 @@ func NewOpenFIGI(c *http.Client, apiKey string) *OpenFIGI { slog.Debug("OpenFIGI client: created with puplic rate limits") } - return &OpenFIGI{ + return &Client{ client: c, apiKey: apiKey, mappingLimiter: limiter, @@ -48,7 +49,7 @@ func NewOpenFIGI(c *http.Client, apiKey string) *OpenFIGI { } } -func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) { +func (of *Client) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) { of.mu.RLock() if secType, ok := of.securityTypeCache[isin]; ok { of.mu.RUnlock() diff --git a/internal/open_figi_test.go b/internal/ofigi/client_test.go similarity index 88% rename from internal/open_figi_test.go rename to internal/ofigi/client_test.go index 3b31107..a203c8b 100644 --- a/internal/open_figi_test.go +++ b/internal/ofigi/client_test.go @@ -1,15 +1,14 @@ -package internal_test +package ofigi_test import ( "bytes" - "context" "fmt" "io" "net/http" "testing" "time" - "github.com/nmoniz/any2anexoj/internal" + "github.com/nmoniz/any2anexoj/internal/ofigi" ) func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) { @@ -110,9 +109,9 @@ func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - of := internal.NewOpenFIGI(tt.client, "") + of := ofigi.NewOpenFIGI(tt.client, "") - got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin) + got, gotErr := of.SecurityTypeByISIN(t.Context(), tt.isin) if gotErr != nil { if !tt.wantErr { t.Errorf("want success but failed: %v", gotErr) @@ -145,7 +144,7 @@ func TestOpenFIGI_SecurityTypeByISIN_Cache(t *testing.T) { }, nil }) - of := internal.NewOpenFIGI(c, "") + of := ofigi.NewOpenFIGI(c, "") got, gotErr := of.SecurityTypeByISIN(t.Context(), "NL0000235190") if gotErr != nil { @@ -171,15 +170,15 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) { wantAPIKey := "123abc-456xyz" c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { - value, ok := req.Header[internal.OpenFIGIAPIKeyHeader] + value, ok := req.Header[ofigi.OpenFIGIAPIKeyHeader] if !ok { - t.Fatalf("want %q header but got none: %v", internal.OpenFIGIAPIKeyHeader, req.Header) + t.Fatalf("want %q header but got none: %v", ofigi.OpenFIGIAPIKeyHeader, req.Header) } if len(value) != 1 { - t.Fatalf("want exactly one %q header value but got %d", internal.OpenFIGIAPIKeyHeader, len(value)) + t.Fatalf("want exactly one %q header value but got %d", ofigi.OpenFIGIAPIKeyHeader, len(value)) } if value[0] != wantAPIKey { - t.Fatalf("want %q header value %q but got %q", internal.OpenFIGIAPIKeyHeader, wantAPIKey, value[0]) + t.Fatalf("want %q header value %q but got %q", ofigi.OpenFIGIAPIKeyHeader, wantAPIKey, value[0]) } return &http.Response{ Status: http.StatusText(http.StatusOK), @@ -187,7 +186,7 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) { Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)), }, nil }) - of := internal.NewOpenFIGI(c, wantAPIKey) + of := ofigi.NewOpenFIGI(c, wantAPIKey) _, err := of.SecurityTypeByISIN(t.Context(), "US1234567890") if err != nil { @@ -197,9 +196,9 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) { t.Run("without API key", func(t *testing.T) { c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { - _, ok := req.Header[internal.OpenFIGIAPIKeyHeader] + _, ok := req.Header[ofigi.OpenFIGIAPIKeyHeader] if ok { - t.Fatalf("want no %s header but got one", internal.OpenFIGIAPIKeyHeader) + t.Fatalf("want no %s header but got one", ofigi.OpenFIGIAPIKeyHeader) } return &http.Response{ Status: http.StatusText(http.StatusOK), @@ -207,7 +206,7 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) { Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)), }, nil }) - of := internal.NewOpenFIGI(c, "") + of := ofigi.NewOpenFIGI(c, "") _, err := of.SecurityTypeByISIN(t.Context(), "US1234567890") if err != nil { t.Fatalf("want success but got an error: %s", err) diff --git a/internal/report.go b/internal/report.go index b8e5a59..1e1eca7 100644 --- a/internal/report.go +++ b/internal/report.go @@ -51,18 +51,58 @@ type ReportWriter interface { Write(context.Context, ReportItem) error } +type optionals struct { + selector Selector + store Store +} + +func applyOptions(defaults optionals, opts []Option) optionals { + for _, opt := range opts { + opt(&defaults) + } + return defaults +} + +type Option func(*optionals) + // Selector returns true if a record should be selected for processing, false otherwise. type Selector func(Record) bool +func WithSelector(s Selector) Option { + return func(o *optionals) { + o.selector = s + } +} + +type Store interface { + Load(context.Context) (map[string]*FillerQueue, error) + Save(context.Context, map[string]*FillerQueue) error +} + +func WithStore(s Store) Option { + return func(o *optionals) { + o.store = s + } +} + // BuildReport reads records from a RecordReader and, if the record passes the Selector, it is // processed into the ReportWriter. -func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, sel Selector) error { - buys := make(map[string]*FillerQueue) +func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, options ...Option) error { + optionals := applyOptions(optionals{ + selector: Any(), + store: EphemeralStore{}, + }, options) - var buysCount, sellsCount int64 - var lastTimestamp time.Time - progTicker := time.NewTicker(10 * time.Second) + buys, err := optionals.store.Load(ctx) + if err != nil { + return fmt.Errorf("loading state: %w", err) + } + var ( + recordsCount int64 + lastTimestamp time.Time + progTicker = time.NewTicker(10 * time.Second) + ) for { select { case <-ctx.Done(): @@ -70,27 +110,29 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, case <-progTicker.C: slog.InfoContext( ctx, "Progress update", - slog.Int64("total_records", buysCount+sellsCount), - slog.Int64("sell_records", sellsCount), - slog.Int64("buy_records", buysCount), + slog.Int64("records_count", recordsCount), slog.Time("last_record_timestamp", lastTimestamp), ) default: rec, err := reader.ReadRecord(ctx) if err != nil { if errors.Is(err, io.EOF) { + err = optionals.store.Save(ctx, buys) + if err != nil { + return fmt.Errorf("saving state: %w", err) + } + return nil } return err } - if rec.Kind().Is(KindBuy) { - buysCount++ - } else if rec.Kind().Is(KindSell) { - sellsCount++ + if !rec.Kind().Valid() { + return fmt.Errorf("found invalid Kind(%d)", rec.Kind()) } lastTimestamp = rec.Timestamp() + recordsCount++ buyQueue, ok := buys[rec.Symbol()] if !ok { @@ -98,21 +140,16 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, buys[rec.Symbol()] = buyQueue } - err = processRecord(ctx, buyQueue, rec, sel, writer) + err = processRecord(ctx, buyQueue, rec, optionals.selector, writer) if err != nil { return fmt.Errorf("processing record: %w", err) } - } } } // processRecord either adds buys to the queue or consumes buys from the queue when processing a // sell record. -// -// NOTE: Selectors are only applied when processing sell records for performance reasons. It's much -// cheaper to just accumulate buys and only actually inspect any records once a sell happens. This -// avoids potential network requests to for every single record. func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector, writer ReportWriter) error { slog.Debug( "Report: processing record", @@ -122,6 +159,9 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector switch rec.Kind() { case KindBuy: + // Selectors are only applied when processing sell records for performance reasons. It's much + // cheaper to just accumulate buys and only actually inspect any records once a sell happens. This + // avoids potential network requests to for every single record. q.Push(NewFiller(rec)) case KindSell: @@ -142,11 +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. - if !sel(buy) { - continue - } - matchedQty, filled := buy.Fill(unmatchedQty) if filled { diff --git a/internal/report_test.go b/internal/report_test.go index 5c5d024..c08748d 100644 --- a/internal/report_test.go +++ b/internal/report_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "reflect" "testing" "time" @@ -43,7 +44,7 @@ func TestBuildReport(t *testing.T) { Taxes: decimal.Decimal{}, })).Times(1) - gotErr := internal.BuildReport(t.Context(), reader, writer, internal.Any()) + gotErr := internal.BuildReport(t.Context(), reader, writer) if gotErr != nil { t.Fatalf("got unexpected err: %v", gotErr) } @@ -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") + } +} diff --git a/internal/state.go b/internal/state.go new file mode 100644 index 0000000..ad70c5f --- /dev/null +++ b/internal/state.go @@ -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 { + RecordData []byte `json:"record_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 +} diff --git a/internal/trading212/record.go b/internal/trading212/record.go index 5d456d1..b34f05b 100644 --- a/internal/trading212/record.go +++ b/internal/trading212/record.go @@ -12,6 +12,7 @@ import ( "github.com/biter777/countries" "github.com/nmoniz/any2anexoj/internal" + "github.com/nmoniz/any2anexoj/internal/ofigi" "github.com/shopspring/decimal" ) @@ -70,10 +71,10 @@ func (r Record) Nature() internal.Nature { type RecordReader struct { reader *csv.Reader - figi *internal.OpenFIGI + figi *ofigi.Client } -func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader { +func NewRecordReader(r io.Reader, f *ofigi.Client) *RecordReader { return &RecordReader{ reader: csv.NewReader(r), figi: f, @@ -187,7 +188,7 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) } } -func figiNatureGetter(ctx context.Context, of *internal.OpenFIGI, isin string) func() internal.Nature { +func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature { return sync.OnceValue(func() internal.Nature { secType, err := of.SecurityTypeByISIN(ctx, isin) if err != nil { diff --git a/internal/trading212/record_test.go b/internal/trading212/record_test.go index d38d32b..ba53cba 100644 --- a/internal/trading212/record_test.go +++ b/internal/trading212/record_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/nmoniz/any2anexoj/internal" + "github.com/nmoniz/any2anexoj/internal/ofigi" "github.com/shopspring/decimal" ) @@ -221,7 +222,7 @@ func TestRecordReader_ReadRecord_Split(t *testing.T) { func Test_figiNatureGetter(t *testing.T) { tests := []struct { name string // description of this test case - of *internal.OpenFIGI + of *ofigi.Client want internal.Nature }{ { @@ -272,7 +273,7 @@ func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } -func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *internal.OpenFIGI { +func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *ofigi.Client { t.Helper() c := &http.Client{ @@ -287,10 +288,10 @@ func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *internal. }), } - return internal.NewOpenFIGI(c, "") + return ofigi.NewOpenFIGI(c, "") } -func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI { +func NewFigiClientErrorStub(t testing.TB, err error) *ofigi.Client { t.Helper() c := &http.Client{ @@ -300,5 +301,5 @@ func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI { }), } - return internal.NewOpenFIGI(c, "") + return ofigi.NewOpenFIGI(c, "") } diff --git a/internal/trading212/serializer.go b/internal/trading212/serializer.go new file mode 100644 index 0000000..68d4899 --- /dev/null +++ b/internal/trading212/serializer.go @@ -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 +} diff --git a/internal/trading212/serializer_test.go b/internal/trading212/serializer_test.go new file mode 100644 index 0000000..a6b0cf2 --- /dev/null +++ b/internal/trading212/serializer_test.go @@ -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 }