Internal state persistence #27
@@ -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/[email protected]
|
||||
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
|
||||
@@ -1,6 +1,5 @@
|
||||
# any2anexoj
|
||||
|
||||
[](https://goreportcard.com/report/github.com/nmoniz/any2anexoj)
|
||||
[](https://coveralls.io/github/nmoniz/any2anexoj?branch=main)
|
||||
|
||||
<p align="center">
|
||||
|
||||
+60
-19
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+18
-2
@@ -22,10 +22,26 @@ func NewFiller(r Record) *Filler {
|
||||
}
|
||||
}
|
||||
|
||||
// NewFillerFromState constructs a Filler from a previously persisted state.
|
||||
// It bypasses the Record-derived defaults so callers can restore a lot that
|
||||
// may have been split-adjusted or partially filled since the Record was first
|
||||
// created.
|
||||
func NewFillerFromState(record Record, quantity, price, filled decimal.Decimal) *Filler {
|
||||
return &Filler{
|
||||
Record: record,
|
||||
filled: filled,
|
||||
quantity: quantity,
|
||||
price: price,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Filler) Quantity() decimal.Decimal { return f.quantity }
|
||||
|
||||
func (f *Filler) Price() decimal.Decimal { return f.price }
|
||||
|
||||
// Filled returns how much of the Filler's quantity has already been consumed.
|
||||
func (f *Filler) Filled() decimal.Decimal { return f.filled }
|
||||
|
||||
// Fill accrues some quantity. Returns how mutch was accrued in the 1st return value and whether
|
||||
// it was filled or not on the 2nd return value.
|
||||
func (f *Filler) Fill(quantity decimal.Decimal) (decimal.Decimal, bool) {
|
||||
@@ -70,8 +86,8 @@ func (fq *FillerQueue) Push(f *Filler) {
|
||||
fq.l.PushBack(f)
|
||||
}
|
||||
|
||||
// Pop removes and returns the first Filler of the queue in the 1st return value. If the list is
|
||||
// empty returns false on the 2nd return value, true otherwise.
|
||||
// Pop removes and returns the first Filler of the queue in the 1st return value if there is one. If
|
||||
// the queue is already empty returns false on the 2nd return value, otherwise returns true.
|
||||
func (fq *FillerQueue) Pop() (*Filler, bool) {
|
||||
el := fq.frontElement()
|
||||
if el == nil {
|
||||
|
||||
@@ -271,3 +271,47 @@ func TestFillerQueue_AdjustForSplit_NilReceiver(t *testing.T) {
|
||||
var fq *FillerQueue
|
||||
fq.AdjustForSplit(decimal.NewFromFloat(5)) // must not panic
|
||||
}
|
||||
|
||||
func TestNewFillerFromState(t *testing.T) {
|
||||
// The underlying Record reports very different values; NewFillerFromState
|
||||
// must ignore them and use the caller-supplied overrides instead.
|
||||
rec := &testRecord{
|
||||
quantity: decimal.NewFromFloat(999),
|
||||
price: decimal.NewFromFloat(999),
|
||||
}
|
||||
|
||||
qty := decimal.NewFromFloat(50)
|
||||
price := decimal.NewFromFloat(20)
|
||||
filled := qty // fully filled on load
|
||||
|
||||
f := NewFillerFromState(rec, qty, price, filled)
|
||||
|
||||
if !f.Quantity().Equal(qty) {
|
||||
t.Errorf("want quantity %v but got %v", qty, f.Quantity())
|
||||
}
|
||||
if !f.Price().Equal(price) {
|
||||
t.Errorf("want price %v but got %v", price, f.Price())
|
||||
}
|
||||
if !f.IsFilled() {
|
||||
t.Errorf("want IsFilled() to be true when filled == quantity")
|
||||
}
|
||||
|
||||
// Partially filled: filled < quantity => IsFilled must be false.
|
||||
partial := NewFillerFromState(rec, qty, price, decimal.NewFromFloat(10))
|
||||
if partial.IsFilled() {
|
||||
t.Errorf("want IsFilled() to be false when filled < quantity")
|
||||
}
|
||||
if !partial.Quantity().Equal(qty) {
|
||||
t.Errorf("want quantity %v but got %v", qty, partial.Quantity())
|
||||
}
|
||||
if !partial.Price().Equal(price) {
|
||||
t.Errorf("want price %v but got %v", price, partial.Price())
|
||||
}
|
||||
|
||||
// Filled from Fill() on a restored lot must work correctly against the
|
||||
// restored (not Record-derived) quantity/price.
|
||||
_, done := partial.Fill(decimal.NewFromFloat(40))
|
||||
if !done {
|
||||
t.Errorf("after filling the remaining 40, IsFilled() should be true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package internal
|
||||
|
||||
//go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
|
||||
//go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer
|
||||
|
||||
+11
-5
@@ -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
|
||||
}
|
||||
|
||||
+80
-45
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+230
-2
@@ -1,9 +1,9 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter)
|
||||
// Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
|
||||
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
@@ -547,3 +547,231 @@ func (c *MockReportWriterWriteCall) DoAndReturn(f func(context.Context, internal
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// MockRecordEncoder is a mock of RecordEncoder interface.
|
||||
type MockRecordEncoder struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRecordEncoderMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRecordEncoderMockRecorder is the mock recorder for MockRecordEncoder.
|
||||
type MockRecordEncoderMockRecorder struct {
|
||||
mock *MockRecordEncoder
|
||||
}
|
||||
|
||||
// NewMockRecordEncoder creates a new mock instance.
|
||||
func NewMockRecordEncoder(ctrl *gomock.Controller) *MockRecordEncoder {
|
||||
mock := &MockRecordEncoder{ctrl: ctrl}
|
||||
mock.recorder = &MockRecordEncoderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRecordEncoder) EXPECT() *MockRecordEncoderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// MarshalRecord mocks base method.
|
||||
func (m *MockRecordEncoder) MarshalRecord(arg0 context.Context, arg1 internal.Record) ([]byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].([]byte)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// MarshalRecord indicates an expected call of MarshalRecord.
|
||||
func (mr *MockRecordEncoderMockRecorder) MarshalRecord(arg0, arg1 any) *MockRecordEncoderMarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarshalRecord", reflect.TypeOf((*MockRecordEncoder)(nil).MarshalRecord), arg0, arg1)
|
||||
return &MockRecordEncoderMarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordEncoderMarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordEncoderMarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordEncoderMarshalRecordCall) Return(arg0 []byte, arg1 error) *MockRecordEncoderMarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordEncoderMarshalRecordCall) Do(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordEncoderMarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordEncoderMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordEncoderMarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// MockRecordDecoder is a mock of RecordDecoder interface.
|
||||
type MockRecordDecoder struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRecordDecoderMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRecordDecoderMockRecorder is the mock recorder for MockRecordDecoder.
|
||||
type MockRecordDecoderMockRecorder struct {
|
||||
mock *MockRecordDecoder
|
||||
}
|
||||
|
||||
// NewMockRecordDecoder creates a new mock instance.
|
||||
func NewMockRecordDecoder(ctrl *gomock.Controller) *MockRecordDecoder {
|
||||
mock := &MockRecordDecoder{ctrl: ctrl}
|
||||
mock.recorder = &MockRecordDecoderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRecordDecoder) EXPECT() *MockRecordDecoderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// UnmarshalRecord mocks base method.
|
||||
func (m *MockRecordDecoder) UnmarshalRecord(arg0 context.Context, arg1 []byte) (internal.Record, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UnmarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].(internal.Record)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UnmarshalRecord indicates an expected call of UnmarshalRecord.
|
||||
func (mr *MockRecordDecoderMockRecorder) UnmarshalRecord(arg0, arg1 any) *MockRecordDecoderUnmarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnmarshalRecord", reflect.TypeOf((*MockRecordDecoder)(nil).UnmarshalRecord), arg0, arg1)
|
||||
return &MockRecordDecoderUnmarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordDecoderUnmarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordDecoderUnmarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordDecoderUnmarshalRecordCall) Return(arg0 internal.Record, arg1 error) *MockRecordDecoderUnmarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordDecoderUnmarshalRecordCall) Do(f func(context.Context, []byte) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordDecoderUnmarshalRecordCall) DoAndReturn(f func(context.Context, []byte) (internal.Record, error)) *MockRecordDecoderUnmarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// MockRecordSerializer is a mock of RecordSerializer interface.
|
||||
type MockRecordSerializer struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRecordSerializerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRecordSerializerMockRecorder is the mock recorder for MockRecordSerializer.
|
||||
type MockRecordSerializerMockRecorder struct {
|
||||
mock *MockRecordSerializer
|
||||
}
|
||||
|
||||
// NewMockRecordSerializer creates a new mock instance.
|
||||
func NewMockRecordSerializer(ctrl *gomock.Controller) *MockRecordSerializer {
|
||||
mock := &MockRecordSerializer{ctrl: ctrl}
|
||||
mock.recorder = &MockRecordSerializerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRecordSerializer) EXPECT() *MockRecordSerializerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// MarshalRecord mocks base method.
|
||||
func (m *MockRecordSerializer) MarshalRecord(arg0 context.Context, arg1 internal.Record) ([]byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].([]byte)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// MarshalRecord indicates an expected call of MarshalRecord.
|
||||
func (mr *MockRecordSerializerMockRecorder) MarshalRecord(arg0, arg1 any) *MockRecordSerializerMarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarshalRecord", reflect.TypeOf((*MockRecordSerializer)(nil).MarshalRecord), arg0, arg1)
|
||||
return &MockRecordSerializerMarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordSerializerMarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordSerializerMarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordSerializerMarshalRecordCall) Return(arg0 []byte, arg1 error) *MockRecordSerializerMarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordSerializerMarshalRecordCall) Do(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordSerializerMarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordSerializerMarshalRecordCall) DoAndReturn(f func(context.Context, internal.Record) ([]byte, error)) *MockRecordSerializerMarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// UnmarshalRecord mocks base method.
|
||||
func (m *MockRecordSerializer) UnmarshalRecord(arg0 context.Context, arg1 []byte) (internal.Record, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UnmarshalRecord", arg0, arg1)
|
||||
ret0, _ := ret[0].(internal.Record)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UnmarshalRecord indicates an expected call of UnmarshalRecord.
|
||||
func (mr *MockRecordSerializerMockRecorder) UnmarshalRecord(arg0, arg1 any) *MockRecordSerializerUnmarshalRecordCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnmarshalRecord", reflect.TypeOf((*MockRecordSerializer)(nil).UnmarshalRecord), arg0, arg1)
|
||||
return &MockRecordSerializerUnmarshalRecordCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordSerializerUnmarshalRecordCall wrap *gomock.Call
|
||||
type MockRecordSerializerUnmarshalRecordCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordSerializerUnmarshalRecordCall) Return(arg0 internal.Record, arg1 error) *MockRecordSerializerUnmarshalRecordCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordSerializerUnmarshalRecordCall) Do(f func(context.Context, []byte) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordSerializerUnmarshalRecordCall) DoAndReturn(f func(context.Context, []byte) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
+58
-23
@@ -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 {
|
||||
|
||||
+86
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package trading212
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// recordState is the on-disk representation of a trading212.Record.
|
||||
// Nature is intentionally omitted because it is resolved lazily via the
|
||||
// OpenFIGI client on demand.
|
||||
type recordState struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Kind internal.Kind `json:"kind"`
|
||||
Quantity decimal.Decimal `json:"quantity"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
Fees decimal.Decimal `json:"fees"`
|
||||
Taxes decimal.Decimal `json:"taxes"`
|
||||
}
|
||||
|
||||
// RecordSerializer encodes and decodes trading212.Record values for the
|
||||
// internal persistence layer. On decode it re-wires the lazy natureGetter to
|
||||
// the supplied OpenFIGI client so a restored Record resolves Nature() via a
|
||||
// fresh API call when needed.
|
||||
type RecordSerializer struct {
|
||||
figi *ofigi.Client
|
||||
}
|
||||
|
||||
// NewRecordSerializer returns a RecordSerializer that uses figi to resolve
|
||||
// Record.Nature() on load.
|
||||
func NewRecordSerializer(figi *ofigi.Client) *RecordSerializer {
|
||||
return &RecordSerializer{figi: figi}
|
||||
}
|
||||
|
||||
// MarshalRecord encodes the given internal.Record as JSON. It returns an
|
||||
// error if the record is not a trading212.Record (or a *trading212.Record).
|
||||
func (s *RecordSerializer) MarshalRecord(_ context.Context, r internal.Record) ([]byte, error) {
|
||||
var rec Record
|
||||
switch v := r.(type) {
|
||||
case Record:
|
||||
rec = v
|
||||
case *Record:
|
||||
if v == nil {
|
||||
return nil, fmt.Errorf("trading212: cannot marshal nil *trading212.Record")
|
||||
}
|
||||
rec = *v
|
||||
default:
|
||||
return nil, fmt.Errorf("trading212: cannot marshal %T as trading212.Record", r)
|
||||
}
|
||||
|
||||
state := recordState{
|
||||
Symbol: rec.symbol,
|
||||
Timestamp: rec.timestamp,
|
||||
Kind: rec.kind,
|
||||
Quantity: rec.quantity,
|
||||
Price: rec.price,
|
||||
Fees: rec.fees,
|
||||
Taxes: rec.taxes,
|
||||
}
|
||||
|
||||
return json.Marshal(state)
|
||||
}
|
||||
|
||||
// UnmarshalRecord decodes a JSON-encoded trading212.Record and re-wires its
|
||||
// natureGetter to the serializer's OpenFIGI client.
|
||||
func (s *RecordSerializer) UnmarshalRecord(ctx context.Context, data []byte) (internal.Record, error) {
|
||||
var state recordState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal trading212 record: %w", err)
|
||||
}
|
||||
|
||||
return Record{
|
||||
symbol: state.Symbol,
|
||||
timestamp: state.Timestamp,
|
||||
kind: state.Kind,
|
||||
quantity: state.Quantity,
|
||||
price: state.Price,
|
||||
fees: state.Fees,
|
||||
taxes: state.Taxes,
|
||||
natureGetter: figiNatureGetter(ctx, s.figi, state.Symbol),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package trading212
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
func TestRecordSerializer_RoundTrip(t *testing.T) {
|
||||
want := Record{
|
||||
symbol: "XX1234567890",
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||
kind: internal.KindBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
}
|
||||
|
||||
s := NewRecordSerializer(NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
||||
|
||||
data, err := s.MarshalRecord(t.Context(), want)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.UnmarshalRecord(t.Context(), data)
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
if got.Symbol() != want.Symbol() {
|
||||
t.Errorf("Symbol: want %q but got %q", want.Symbol(), got.Symbol())
|
||||
}
|
||||
if got.Kind() != want.Kind() {
|
||||
t.Errorf("Kind: want %v but got %v", want.Kind(), got.Kind())
|
||||
}
|
||||
if !got.Price().Equal(want.Price()) {
|
||||
t.Errorf("Price: want %v but got %v", want.Price(), got.Price())
|
||||
}
|
||||
if !got.Quantity().Equal(want.Quantity()) {
|
||||
t.Errorf("Quantity: want %v but got %v", want.Quantity(), got.Quantity())
|
||||
}
|
||||
if !got.Fees().Equal(want.Fees()) {
|
||||
t.Errorf("Fees: want %v but got %v", want.Fees(), got.Fees())
|
||||
}
|
||||
if !got.Taxes().Equal(want.Taxes()) {
|
||||
t.Errorf("Taxes: want %v but got %v", want.Taxes(), got.Taxes())
|
||||
}
|
||||
if !got.Timestamp().Equal(want.Timestamp()) {
|
||||
t.Errorf("Timestamp: want %v but got %v", want.Timestamp(), got.Timestamp())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSerializer_UnmarshalRecord_NatureTriggersOpenFIGI(t *testing.T) {
|
||||
var calls int
|
||||
client := &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
s := NewRecordSerializer(ofigi.NewOpenFIGI(client, ""))
|
||||
|
||||
original := Record{
|
||||
symbol: "XX1234567890",
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||
kind: internal.KindBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
// Pre-populated so MarshalRecord doesn't accidentally trigger an
|
||||
// OpenFIGI call when encoding the original record.
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
}
|
||||
|
||||
data, err := s.MarshalRecord(t.Context(), original)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
if calls != 0 {
|
||||
t.Fatalf("OpenFIGI called during MarshalRecord: %d", calls)
|
||||
}
|
||||
|
||||
got, err := s.UnmarshalRecord(t.Context(), data)
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalRecord: %v", err)
|
||||
}
|
||||
|
||||
// Nature must not have been resolved yet — natureGetter is lazy.
|
||||
if calls != 0 {
|
||||
t.Fatalf("OpenFIGI called before Nature(): %d", calls)
|
||||
}
|
||||
|
||||
if nature := got.Nature(); nature != internal.NatureG01 {
|
||||
t.Errorf("Nature: want %v but got %v", internal.NatureG01, nature)
|
||||
}
|
||||
|
||||
if calls != 1 {
|
||||
t.Errorf("OpenFIGI request count: want 1 but got %d", calls)
|
||||
}
|
||||
|
||||
// Subsequent Nature() calls should not re-trigger the request (the
|
||||
// underlying sync.OnceValue caches the result on the client too).
|
||||
if nature := got.Nature(); nature != internal.NatureG01 {
|
||||
t.Errorf("Nature (cached): want %v but got %v", internal.NatureG01, nature)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("OpenFIGI request count after re-read: want 1 but got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSerializer_MarshalRecord_WrongType(t *testing.T) {
|
||||
s := NewRecordSerializer(NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
||||
|
||||
_, err := s.MarshalRecord(t.Context(), stubRecord{})
|
||||
if err == nil {
|
||||
t.Fatal("want error but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// stubRecord is a non-trading212 implementation of internal.Record used to
|
||||
// verify that MarshalRecord rejects unrelated record types.
|
||||
type stubRecord struct{}
|
||||
|
||||
func (stubRecord) Symbol() string { return "STUB" }
|
||||
func (stubRecord) Nature() internal.Nature { return internal.NatureUnknown }
|
||||
func (stubRecord) BrokerCountry() int64 { return 0 }
|
||||
func (stubRecord) AssetCountry() int64 { return 0 }
|
||||
func (stubRecord) Kind() internal.Kind { return internal.KindUnknown }
|
||||
func (stubRecord) Price() decimal.Decimal { return decimal.Zero }
|
||||
func (stubRecord) Quantity() decimal.Decimal { return decimal.Zero }
|
||||
func (stubRecord) Timestamp() time.Time { return time.Time{} }
|
||||
func (stubRecord) Fees() decimal.Decimal { return decimal.Zero }
|
||||
func (stubRecord) Taxes() decimal.Decimal { return decimal.Zero }
|
||||
Reference in New Issue
Block a user