Compare commits

..
3 Commits
Author SHA1 Message Date
natercio 9088f6bb14 improve docs 2026-07-10 17:17:14 +01:00
natercio 52949200da skip buy records not covered by selector 2026-07-10 17:16:55 +01:00
natercio 15bb408f9f fix licenses 2026-07-10 17:15:45 +01:00
29 changed files with 200 additions and 1782 deletions
+43
View File
@@ -0,0 +1,43 @@
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 -33
View File
@@ -24,37 +24,6 @@ jobs:
echo "has_go_changes=false" >> $GITHUB_OUTPUT echo "has_go_changes=false" >> $GITHUB_OUTPUT
fi fi
static-checks:
runs-on: ubuntu-latest
needs: check-changes
if: needs.check-changes.outputs.has_go_changes == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.26
cache: true
- name: Run go vet
run: go vet ./...
- name: Check go mod tidy
run: |
go mod tidy
if [ -n "$(git status --porcelain go.mod go.sum)" ]; then
echo "go.mod or go.sum changed after go mod tidy; please run go mod tidy and commit the result" >&2
git diff go.mod go.sum >&2
exit 1
fi
- name: Run govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
run-tests: run-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: check-changes needs: check-changes
@@ -66,8 +35,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: 1.26 go-version: 1.25
cache: true
- name: Run Unit tests - name: Run Unit tests
run: | run: |
+16 -17
View File
@@ -151,7 +151,7 @@ Source.
The Corresponding Source for a work in source code form is that The Corresponding Source for a work in source code form is that
same work. same work.
2. Basic Permissions. 1. Basic Permissions.
All rights granted under this License are granted for the term of All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated copyright on the Program, and are irrevocable provided the stated
@@ -176,7 +176,7 @@ your copyrighted material outside their relationship with you.
the conditions stated below. Sublicensing is not allowed; section 10 the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary. makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law. 1. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article measure under any applicable law fulfilling obligations under article
@@ -192,7 +192,7 @@ modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of users, your or third parties' legal rights to forbid circumvention of
technological measures. technological measures.
4. Conveying Verbatim Copies. 1. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and receive it, in any medium, provided that you conspicuously and
@@ -205,7 +205,7 @@ recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee. and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions. 1. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the produce it from the Program, in the form of source code under the
@@ -242,7 +242,7 @@ beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other in an aggregate does not cause this License to apply to the other
parts of the aggregate. parts of the aggregate.
6. Conveying Non-Source Forms. 1. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the of sections 4 and 5, provided that you also convey the
@@ -340,7 +340,7 @@ documented (and with an implementation available to the public in
source code form), and must require no special password or key for source code form), and must require no special password or key for
unpacking, reading or copying. unpacking, reading or copying.
7. Additional Terms. 1. Additional Terms.
"Additional permissions" are terms that supplement the terms of this "Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions. License by making exceptions from one or more of its conditions.
@@ -404,7 +404,7 @@ where to find the applicable terms.
form of a separately written license, or stated as exceptions; form of a separately written license, or stated as exceptions;
the above requirements apply either way. the above requirements apply either way.
8. Termination. 1. Termination.
You may not propagate or modify a covered work except as expressly You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or provided under this License. Any attempt otherwise to propagate or
@@ -432,7 +432,7 @@ this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same reinstated, you do not qualify to receive new licenses for the same
material under section 10. material under section 10.
9. Acceptance Not Required for Having Copies. 1. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work run a copy of the Program. Ancillary propagation of a covered work
@@ -443,7 +443,7 @@ modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so. covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients. 1. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and receives a license from the original licensors, to run, modify and
@@ -468,7 +468,7 @@ rights granted under this License, and you may not initiate litigation
any patent claim is infringed by making, using, selling, offering for any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it. sale, or importing the Program or any portion of it.
11. Patents. 1. Patents.
A "contributor" is a copyright holder who authorizes use under this A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The License of the Program or a work on which the Program is based. The
@@ -537,7 +537,7 @@ or that patent license was granted, prior to 28 March 2007.
any implied license or other defenses to infringement that may any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law. otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom. 1. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not otherwise) that contradict the conditions of this License, they do not
@@ -549,7 +549,7 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program. License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License. 1. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed permission to link or combine any covered work with a work licensed
@@ -560,7 +560,7 @@ but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the section 13, concerning interaction through a network will apply to the
combination as such. combination as such.
14. Revised Versions of this License. 1. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will the GNU General Public License from time to time. Such new versions will
@@ -586,7 +586,7 @@ permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a author or copyright holder as a result of your choosing to follow a
later version. later version.
15. Disclaimer of Warranty. 1. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
@@ -597,7 +597,7 @@ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION. ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability. 1. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
@@ -609,7 +609,7 @@ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES. SUCH DAMAGES.
17. Interpretation of Sections 15 and 16. 1. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms, above cannot be given local legal effect according to their terms,
@@ -617,4 +617,3 @@ reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee. copy of the Program in return for a fee.
+1
View File
@@ -1,5 +1,6 @@
# any2anexoj # 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) [![Coverage Status](https://coveralls.io/repos/github/nmoniz/any2anexoj/badge.svg?branch=main)](https://coveralls.io/github/nmoniz/any2anexoj?branch=main)
<p align="center"> <p align="center">
+14 -55
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -11,9 +10,9 @@ import (
"time" "time"
"github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/ofigi"
"github.com/nmoniz/any2anexoj/internal/trading212" "github.com/nmoniz/any2anexoj/internal/trading212"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"golang.org/x/sync/errgroup"
"golang.org/x/text/language" "golang.org/x/text/language"
) )
@@ -25,8 +24,8 @@ var (
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages") debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
format = pflag.StringP("format", "f", "table", "Output format: table or csv") 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)") 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
selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors: code, assetCountry") 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() { func main() {
@@ -39,19 +38,12 @@ 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 { func run(ctx context.Context) error {
ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt) ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt)
defer cancel() defer cancel()
return runWithIO(ctx, os.Stdin, os.Stdout)
}
// runWithIO is the testable core of the CLI. It reads broker records from eg, ctx := errgroup.WithContext(ctx)
// 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 logLevel := slog.LevelInfo
if *debug { if *debug {
logLevel = slog.LevelDebug logLevel = slog.LevelDebug
@@ -66,18 +58,11 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
return fmt.Errorf("--language flag is required") return fmt.Errorf("--language flag is required")
} }
figiClient := ofigi.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, *ofAPIKey) reader, err := getReader(*platform, *ofAPIKey)
reader, err := getReader(*platform, stdin, figiClient)
if err != nil { if err != nil {
return fmt.Errorf("getting reader: %w", err) return fmt.Errorf("getting reader: %w", err)
} }
store, err := buildStore(*stateFile, *platform, figiClient)
if err != nil {
return err
}
writer := internal.NewAggregatorWriter() writer := internal.NewAggregatorWriter()
selector, err := internal.ParseSelectors(*selectors) selector, err := internal.ParseSelectors(*selectors)
@@ -85,60 +70,34 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
return fmt.Errorf("parsing selectors: %w", err) return fmt.Errorf("parsing selectors: %w", err)
} }
err = internal.BuildReport( eg.Go(func() error {
ctx, return internal.BuildReport(ctx, reader, writer, selector)
reader, })
writer,
internal.WithSelector(selector), err = eg.Wait()
internal.WithStore(store),
)
if err != nil { if err != nil {
return err return err
} }
switch *format { switch *format {
case "csv": case "csv":
return NewCSVWriter(stdout).Render(writer) return NewCSVWriter(os.Stdout).Render(writer)
case "table": case "table":
loc, err := NewLocalizer(*lang) loc, err := NewLocalizer(*lang)
if err != nil { if err != nil {
return fmt.Errorf("create localizer: %w", err) return fmt.Errorf("create localizer: %w", err)
} }
NewPrettyPrinter(stdout, loc).Render(writer) NewPrettyPrinter(os.Stdout, loc).Render(writer)
return nil return nil
default: default:
return fmt.Errorf("unsupported format %q: must be table or csv", *format) return fmt.Errorf("unsupported format %q: must be table or csv", *format)
} }
} }
// buildStore returns the Store implementation that BuildReport should use. func getReader(platform string, ofAPIKey string) (internal.RecordReader, error) {
// 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 { switch platform {
case "trading212": case "trading212":
serializer = trading212.NewRecordSerializer(figi) return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, ofAPIKey)), nil
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: default:
return nil, fmt.Errorf("unsupported platform: %s", platform) return nil, fmt.Errorf("unsupported platform: %s", platform)
} }
-168
View File
@@ -1,168 +0,0 @@
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 -1
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"context"
"testing" "testing"
"time" "time"
@@ -12,7 +13,7 @@ import (
func TestPrettyPrinter_Render(t *testing.T) { func TestPrettyPrinter_Render(t *testing.T) {
// Create test data // Create test data
aw := internal.NewAggregatorWriter() aw := internal.NewAggregatorWriter()
ctx := t.Context() ctx := context.Background()
// Add some sample report items // Add some sample report items
err := aw.Write(ctx, internal.ReportItem{ err := aw.Write(ctx, internal.ReportItem{
+3 -4
View File
@@ -1,6 +1,6 @@
module github.com/nmoniz/any2anexoj module github.com/nmoniz/any2anexoj
go 1.26.4 go 1.25.3
require ( require (
github.com/biter777/countries v1.7.5 github.com/biter777/countries v1.7.5
@@ -8,18 +8,17 @@ require (
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
go.uber.org/mock v0.6.0 go.uber.org/mock v0.6.0
golang.org/x/sync v0.18.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
) )
require golang.org/x/sync v0.18.0 // indirect
require ( require (
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.0 github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/mod v0.27.0 // indirect golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.23.0 golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.36.0 // indirect golang.org/x/tools v0.36.0 // indirect
) )
+2 -2
View File
@@ -1,5 +1,3 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q= github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q=
github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E= github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -31,6 +29,8 @@ golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+5 -4
View File
@@ -1,6 +1,7 @@
package internal_test package internal_test
import ( import (
"context"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -90,7 +91,7 @@ func TestAggregatorWriter_Write(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
aw := &internal.AggregatorWriter{} aw := &internal.AggregatorWriter{}
ctx := t.Context() ctx := context.Background()
for _, item := range tt.items { for _, item := range tt.items {
if err := aw.Write(ctx, item); err != nil { if err := aw.Write(ctx, item); err != nil {
@@ -190,7 +191,7 @@ func TestAggregatorWriter_Rounding(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
aw := &internal.AggregatorWriter{} aw := &internal.AggregatorWriter{}
ctx := t.Context() ctx := context.Background()
for _, item := range tt.items { for _, item := range tt.items {
if err := aw.Write(ctx, item); err != nil { if err := aw.Write(ctx, item); err != nil {
@@ -208,7 +209,7 @@ func TestAggregatorWriter_Rounding(t *testing.T) {
func TestAggregatorWriter_Items(t *testing.T) { func TestAggregatorWriter_Items(t *testing.T) {
aw := &internal.AggregatorWriter{} aw := &internal.AggregatorWriter{}
ctx := t.Context() ctx := context.Background()
for range 5 { for range 5 {
item := internal.ReportItem{Symbol: "TEST"} item := internal.ReportItem{Symbol: "TEST"}
@@ -240,7 +241,7 @@ func TestAggregatorWriter_Items(t *testing.T) {
func TestAggregatorWriter_ThreadSafety(t *testing.T) { func TestAggregatorWriter_ThreadSafety(t *testing.T) {
aw := &internal.AggregatorWriter{} aw := &internal.AggregatorWriter{}
ctx := t.Context() ctx := context.Background()
numGoroutines := 100 numGoroutines := 100
writesPerGoroutine := 100 writesPerGoroutine := 100
-14
View File
@@ -1,14 +0,0 @@
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
}
-47
View File
@@ -1,47 +0,0 @@
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))
}
}
-153
View File
@@ -1,153 +0,0 @@
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
}
-435
View File
@@ -1,435 +0,0 @@
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
}
+2 -18
View File
@@ -22,26 +22,10 @@ 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) Quantity() decimal.Decimal { return f.quantity }
func (f *Filler) Price() decimal.Decimal { return f.price } 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 // 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. // it was filled or not on the 2nd return value.
func (f *Filler) Fill(quantity decimal.Decimal) (decimal.Decimal, bool) { func (f *Filler) Fill(quantity decimal.Decimal) (decimal.Decimal, bool) {
@@ -86,8 +70,8 @@ func (fq *FillerQueue) Push(f *Filler) {
fq.l.PushBack(f) fq.l.PushBack(f)
} }
// Pop removes and returns the first Filler of the queue in the 1st return value if there is one. If // Pop removes and returns the first Filler of the queue in the 1st return value. If the list is
// the queue is already empty returns false on the 2nd return value, otherwise returns true. // empty returns false on the 2nd return value, true otherwise.
func (fq *FillerQueue) Pop() (*Filler, bool) { func (fq *FillerQueue) Pop() (*Filler, bool) {
el := fq.frontElement() el := fq.frontElement()
if el == nil { if el == nil {
-44
View File
@@ -271,47 +271,3 @@ func TestFillerQueue_AdjustForSplit_NilReceiver(t *testing.T) {
var fq *FillerQueue var fq *FillerQueue
fq.AdjustForSplit(decimal.NewFromFloat(5)) // must not panic fq.AdjustForSplit(decimal.NewFromFloat(5)) // must not panic
} }
func TestNewFillerFromState(t *testing.T) {
// The underlying Record reports very different values; NewFillerFromState
// must ignore them and use the caller-supplied overrides instead.
rec := &testRecord{
quantity: decimal.NewFromFloat(999),
price: decimal.NewFromFloat(999),
}
qty := decimal.NewFromFloat(50)
price := decimal.NewFromFloat(20)
filled := qty // fully filled on load
f := NewFillerFromState(rec, qty, price, filled)
if !f.Quantity().Equal(qty) {
t.Errorf("want quantity %v but got %v", qty, f.Quantity())
}
if !f.Price().Equal(price) {
t.Errorf("want price %v but got %v", price, f.Price())
}
if !f.IsFilled() {
t.Errorf("want IsFilled() to be true when filled == quantity")
}
// Partially filled: filled < quantity => IsFilled must be false.
partial := NewFillerFromState(rec, qty, price, decimal.NewFromFloat(10))
if partial.IsFilled() {
t.Errorf("want IsFilled() to be false when filled < quantity")
}
if !partial.Quantity().Equal(qty) {
t.Errorf("want quantity %v but got %v", qty, partial.Quantity())
}
if !partial.Price().Equal(price) {
t.Errorf("want price %v but got %v", price, partial.Price())
}
// Filled from Fill() on a restored lot must work correctly against the
// restored (not Record-derived) quantity/price.
_, done := partial.Fill(decimal.NewFromFloat(40))
if !done {
t.Errorf("after filling the remaining 40, IsFilled() should be true")
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
package internal package internal
//go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer //go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
+5 -11
View File
@@ -7,12 +7,11 @@ const (
KindBuy KindBuy
KindSell KindSell
KindSplit KindSplit
sentinelKind
) )
// String returns a unique string value for Kind k // String returns a human readable value
func (k Kind) String() string { func (d Kind) String() string {
switch k { switch d {
case KindBuy: case KindBuy:
return "buy" return "buy"
case KindSell: case KindSell:
@@ -24,13 +23,8 @@ func (k Kind) String() string {
} }
} }
// Valid returns true if k is an accepted value for the Kind type // Is returns true when k equals o
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 { func (k Kind) Is(o any) bool {
other, ok := o.(Kind) other, ok := o.(Kind)
return ok && k.Valid() && k == other return ok && k == other
} }
+46 -81
View File
@@ -1,95 +1,60 @@
package internal package internal
import ( import "testing"
"fmt"
"testing"
)
func TestSide_String(t *testing.T) { func TestSide_String(t *testing.T) {
const unknown = "unknown" tests := []struct {
name string
seen := make(map[string]Kind, sentinelKind) side Kind
for k := Kind(1); k < sentinelKind; k++ { want string
t.Run(fmt.Sprintf("Kind %d", k), func(t *testing.T) { }{
str := k.String() {"buy", KindBuy, "buy"},
{"sell", KindSell, "sell"},
if other, ok := seen[str]; ok { {"unknown", KindUnknown, "unknown"},
t.Errorf("want Kind(%d).String to be unique but was a duplicate of Kind(%d)", k, other)
} else {
seen[str] = k
} }
for _, tt := range tests {
if len(str) == 0 { t.Run(tt.name, func(t *testing.T) {
t.Errorf("want Kind(%d).String to be non-empty", k) if got := tt.side.String(); got != tt.want {
} t.Errorf("want Side.String() to be %v but got %v", tt.want, got)
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 { func TestSide_IsBuy(t *testing.T) {
t.Errorf("want Kind(%d) to be unknown", sentinelKind) tests := []struct {
name string
side Kind
want bool
}{
{"buy", KindBuy, true},
{"sell", KindSell, false},
{"unknown", KindUnknown, false},
} }
} for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
func TestSide_Valid(t *testing.T) { if got := tt.side.Is(KindBuy); got != tt.want {
for k := Kind(1); k < sentinelKind; k++ { t.Errorf("want Side.IsBuy() to be %v but got %v", tt.want, got)
if !k.Valid() {
t.Errorf("want %s(%d) to be valid", k, k)
}
}
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_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)
} }
}) })
} }
}
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)
}
})
}
}
+2 -230
View File
@@ -1,9 +1,9 @@
// Code generated by MockGen. DO NOT EDIT. // Code generated by MockGen. DO NOT EDIT.
// Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer) // Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter)
// //
// Generated by this command: // Generated by this command:
// //
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer // mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter
// //
// Package mocks is a generated GoMock package. // Package mocks is a generated GoMock package.
@@ -547,231 +547,3 @@ func (c *MockReportWriterWriteCall) DoAndReturn(f func(context.Context, internal
c.Call = c.Call.DoAndReturn(f) c.Call = c.Call.DoAndReturn(f)
return c 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 ofigi package internal
import ( import (
"bytes" "bytes"
@@ -16,8 +16,8 @@ import (
var OpenFIGIAPIKeyHeader = http.CanonicalHeaderKey("X-OPENFIGI-APIKEY") var OpenFIGIAPIKeyHeader = http.CanonicalHeaderKey("X-OPENFIGI-APIKEY")
// Client is a thin adapter for the openfigi.com api. // OpenFIGI is a small adapter for the openfigi.com api.
type Client struct { type OpenFIGI struct {
client *http.Client client *http.Client
apiKey string apiKey string
mappingLimiter *rate.Limiter mappingLimiter *rate.Limiter
@@ -25,13 +25,12 @@ type Client struct {
mu sync.RWMutex mu sync.RWMutex
// TODO: there's no eviction policy at the moment as this is only used by short-lived application // 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 // 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 eviction // cache lib (like golang-lru or go-cache) if this becomes a problem or implement this ourselves.
// ourselves.
securityTypeCache map[string]string securityTypeCache map[string]string
} }
// NewOpenFIGI creates an OpenFIGI client that uses the API key if provided // NewOpenFIGI creates an OpenFIGI client that uses the API key if provided
func NewOpenFIGI(c *http.Client, apiKey string) *Client { func NewOpenFIGI(c *http.Client, apiKey string) *OpenFIGI {
// Rate limits as per https://www.openfigi.com/api/documentation#rate-limits // Rate limits as per https://www.openfigi.com/api/documentation#rate-limits
limiter := rate.NewLimiter(rate.Every(time.Minute), 25) limiter := rate.NewLimiter(rate.Every(time.Minute), 25)
if len(apiKey) > 0 { if len(apiKey) > 0 {
@@ -41,7 +40,7 @@ func NewOpenFIGI(c *http.Client, apiKey string) *Client {
slog.Debug("OpenFIGI client: created with puplic rate limits") slog.Debug("OpenFIGI client: created with puplic rate limits")
} }
return &Client{ return &OpenFIGI{
client: c, client: c,
apiKey: apiKey, apiKey: apiKey,
mappingLimiter: limiter, mappingLimiter: limiter,
@@ -49,7 +48,7 @@ func NewOpenFIGI(c *http.Client, apiKey string) *Client {
} }
} }
func (of *Client) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) { func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) {
of.mu.RLock() of.mu.RLock()
if secType, ok := of.securityTypeCache[isin]; ok { if secType, ok := of.securityTypeCache[isin]; ok {
of.mu.RUnlock() of.mu.RUnlock()
@@ -1,14 +1,15 @@
package ofigi_test package internal_test
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"testing" "testing"
"time" "time"
"github.com/nmoniz/any2anexoj/internal/ofigi" "github.com/nmoniz/any2anexoj/internal"
) )
func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) { func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) {
@@ -109,9 +110,9 @@ func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
of := ofigi.NewOpenFIGI(tt.client, "") of := internal.NewOpenFIGI(tt.client, "")
got, gotErr := of.SecurityTypeByISIN(t.Context(), tt.isin) got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin)
if gotErr != nil { if gotErr != nil {
if !tt.wantErr { if !tt.wantErr {
t.Errorf("want success but failed: %v", gotErr) t.Errorf("want success but failed: %v", gotErr)
@@ -144,7 +145,7 @@ func TestOpenFIGI_SecurityTypeByISIN_Cache(t *testing.T) {
}, nil }, nil
}) })
of := ofigi.NewOpenFIGI(c, "") of := internal.NewOpenFIGI(c, "")
got, gotErr := of.SecurityTypeByISIN(t.Context(), "NL0000235190") got, gotErr := of.SecurityTypeByISIN(t.Context(), "NL0000235190")
if gotErr != nil { if gotErr != nil {
@@ -170,15 +171,15 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) {
wantAPIKey := "123abc-456xyz" wantAPIKey := "123abc-456xyz"
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
value, ok := req.Header[ofigi.OpenFIGIAPIKeyHeader] value, ok := req.Header[internal.OpenFIGIAPIKeyHeader]
if !ok { if !ok {
t.Fatalf("want %q header but got none: %v", ofigi.OpenFIGIAPIKeyHeader, req.Header) t.Fatalf("want %q header but got none: %v", internal.OpenFIGIAPIKeyHeader, req.Header)
} }
if len(value) != 1 { if len(value) != 1 {
t.Fatalf("want exactly one %q header value but got %d", ofigi.OpenFIGIAPIKeyHeader, len(value)) t.Fatalf("want exactly one %q header value but got %d", internal.OpenFIGIAPIKeyHeader, len(value))
} }
if value[0] != wantAPIKey { if value[0] != wantAPIKey {
t.Fatalf("want %q header value %q but got %q", ofigi.OpenFIGIAPIKeyHeader, wantAPIKey, value[0]) t.Fatalf("want %q header value %q but got %q", internal.OpenFIGIAPIKeyHeader, wantAPIKey, value[0])
} }
return &http.Response{ return &http.Response{
Status: http.StatusText(http.StatusOK), Status: http.StatusText(http.StatusOK),
@@ -186,7 +187,7 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) {
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)), Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
}, nil }, nil
}) })
of := ofigi.NewOpenFIGI(c, wantAPIKey) of := internal.NewOpenFIGI(c, wantAPIKey)
_, err := of.SecurityTypeByISIN(t.Context(), "US1234567890") _, err := of.SecurityTypeByISIN(t.Context(), "US1234567890")
if err != nil { if err != nil {
@@ -196,9 +197,9 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) {
t.Run("without API key", func(t *testing.T) { t.Run("without API key", func(t *testing.T) {
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
_, ok := req.Header[ofigi.OpenFIGIAPIKeyHeader] _, ok := req.Header[internal.OpenFIGIAPIKeyHeader]
if ok { if ok {
t.Fatalf("want no %s header but got one", ofigi.OpenFIGIAPIKeyHeader) t.Fatalf("want no %s header but got one", internal.OpenFIGIAPIKeyHeader)
} }
return &http.Response{ return &http.Response{
Status: http.StatusText(http.StatusOK), Status: http.StatusText(http.StatusOK),
@@ -206,7 +207,7 @@ func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) {
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)), Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
}, nil }, nil
}) })
of := ofigi.NewOpenFIGI(c, "") of := internal.NewOpenFIGI(c, "")
_, err := of.SecurityTypeByISIN(t.Context(), "US1234567890") _, err := of.SecurityTypeByISIN(t.Context(), "US1234567890")
if err != nil { if err != nil {
t.Fatalf("want success but got an error: %s", err) t.Fatalf("want success but got an error: %s", err)
+23 -58
View File
@@ -51,58 +51,18 @@ type ReportWriter interface {
Write(context.Context, ReportItem) error 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. // Selector returns true if a record should be selected for processing, false otherwise.
type Selector func(Record) bool 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 // BuildReport reads records from a RecordReader and, if the record passes the Selector, it is
// processed into the ReportWriter. // processed into the ReportWriter.
func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, options ...Option) error { func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, sel Selector) error {
optionals := applyOptions(optionals{ buys := make(map[string]*FillerQueue)
selector: Any(),
store: EphemeralStore{},
}, options)
buys, err := optionals.store.Load(ctx) var buysCount, sellsCount int64
if err != nil { var lastTimestamp time.Time
return fmt.Errorf("loading state: %w", err) progTicker := time.NewTicker(10 * time.Second)
}
var (
recordsCount int64
lastTimestamp time.Time
progTicker = time.NewTicker(10 * time.Second)
)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -110,29 +70,27 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter,
case <-progTicker.C: case <-progTicker.C:
slog.InfoContext( slog.InfoContext(
ctx, "Progress update", ctx, "Progress update",
slog.Int64("records_count", recordsCount), slog.Int64("total_records", buysCount+sellsCount),
slog.Int64("sell_records", sellsCount),
slog.Int64("buy_records", buysCount),
slog.Time("last_record_timestamp", lastTimestamp), slog.Time("last_record_timestamp", lastTimestamp),
) )
default: default:
rec, err := reader.ReadRecord(ctx) rec, err := reader.ReadRecord(ctx)
if err != nil { if err != nil {
if errors.Is(err, io.EOF) { 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 nil
} }
return err return err
} }
if !rec.Kind().Valid() { if rec.Kind().Is(KindBuy) {
return fmt.Errorf("found invalid Kind(%d)", rec.Kind()) buysCount++
} else if rec.Kind().Is(KindSell) {
sellsCount++
} }
lastTimestamp = rec.Timestamp() lastTimestamp = rec.Timestamp()
recordsCount++
buyQueue, ok := buys[rec.Symbol()] buyQueue, ok := buys[rec.Symbol()]
if !ok { if !ok {
@@ -140,16 +98,21 @@ func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter,
buys[rec.Symbol()] = buyQueue buys[rec.Symbol()] = buyQueue
} }
err = processRecord(ctx, buyQueue, rec, optionals.selector, writer) err = processRecord(ctx, buyQueue, rec, sel, writer)
if err != nil { if err != nil {
return fmt.Errorf("processing record: %w", err) return fmt.Errorf("processing record: %w", err)
} }
} }
} }
} }
// processRecord either adds buys to the queue or consumes buys from the queue when processing a // processRecord either adds buys to the queue or consumes buys from the queue when processing a
// sell record. // 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 { func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector, writer ReportWriter) error {
slog.Debug( slog.Debug(
"Report: processing record", "Report: processing record",
@@ -159,9 +122,6 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector
switch rec.Kind() { switch rec.Kind() {
case KindBuy: 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)) q.Push(NewFiller(rec))
case KindSell: case KindSell:
@@ -182,6 +142,11 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector
return ErrInsufficientBoughtVolume 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) matchedQty, filled := buy.Fill(unmatchedQty)
if filled { if filled {
+1 -86
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"reflect"
"testing" "testing"
"time" "time"
@@ -44,7 +43,7 @@ func TestBuildReport(t *testing.T) {
Taxes: decimal.Decimal{}, Taxes: decimal.Decimal{},
})).Times(1) })).Times(1)
gotErr := internal.BuildReport(t.Context(), reader, writer) gotErr := internal.BuildReport(t.Context(), reader, writer, internal.Any())
if gotErr != nil { if gotErr != nil {
t.Fatalf("got unexpected err: %v", gotErr) t.Fatalf("got unexpected err: %v", gotErr)
} }
@@ -99,87 +98,3 @@ func (m ReportItemMatcher) String() string {
} }
var _ gomock.Matcher = (*ReportItemMatcher)(nil) var _ gomock.Matcher = (*ReportItemMatcher)(nil)
// recordingStore is a test fake for internal.Store that records how many
// times Load/Save were invoked and captures the maps it handed back so a
// test can verify BuildReport wires the store correctly.
type recordingStore struct {
loadCalls int
saveCalls int
loaded map[string]*internal.FillerQueue
saved map[string]*internal.FillerQueue
}
func (s *recordingStore) Load(_ context.Context) (map[string]*internal.FillerQueue, error) {
s.loadCalls++
if s.loaded == nil {
s.loaded = map[string]*internal.FillerQueue{}
}
return s.loaded, nil
}
func (s *recordingStore) Save(_ context.Context, queues map[string]*internal.FillerQueue) error {
s.saveCalls++
s.saved = queues
return nil
}
func TestBuildReport_WithStore(t *testing.T) {
now := time.Now()
ctrl := gomock.NewController(t)
reader := mocks.NewMockRecordReader(ctrl)
records := []internal.Record{
mockRecord(ctrl, 20.0, 10.0, internal.KindBuy, now),
}
reader.EXPECT().ReadRecord(gomock.Any()).DoAndReturn(func(ctx context.Context) (internal.Record, error) {
if len(records) > 0 {
r := records[0]
records = records[1:]
return r, nil
}
return nil, io.EOF
}).Times(2)
// No sells, so the writer must not be called.
writer := mocks.NewMockReportWriter(ctrl)
store := &recordingStore{}
gotErr := internal.BuildReport(t.Context(), reader, writer, internal.WithStore(store))
if gotErr != nil {
t.Fatalf("got unexpected err: %v", gotErr)
}
if store.loadCalls != 1 {
t.Errorf("Load calls: want 1 but got %d", store.loadCalls)
}
if store.saveCalls != 1 {
t.Errorf("Save calls: want 1 but got %d", store.saveCalls)
}
// Load must run before any records are read, so it has already been
// invoked by the time Save is observed.
if store.loadCalls < store.saveCalls {
t.Errorf("Load (%d) should be called at least as many times as Save (%d)", store.loadCalls, store.saveCalls)
}
if store.loaded == nil {
t.Fatalf("Load was not called or returned a nil map")
}
if store.saved == nil {
t.Fatalf("Save was not called or received a nil map")
}
// The map handed to Save must be the same map returned by Load so
// that buy-queue mutations done while processing flow into Save.
if reflect.ValueOf(store.loaded).Pointer() != reflect.ValueOf(store.saved).Pointer() {
t.Errorf("map passed to Save is not the same map returned by Load")
}
// Sanity: the buy record above should have populated an entry for the
// "TEST" symbol in the shared map.
if _, ok := store.saved["TEST"]; !ok {
t.Errorf("want saved map to contain symbol %q but it was missing", "TEST")
}
}
-46
View File
@@ -1,46 +0,0 @@
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
}
+3 -4
View File
@@ -12,7 +12,6 @@ import (
"github.com/biter777/countries" "github.com/biter777/countries"
"github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/ofigi"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
@@ -71,10 +70,10 @@ func (r Record) Nature() internal.Nature {
type RecordReader struct { type RecordReader struct {
reader *csv.Reader reader *csv.Reader
figi *ofigi.Client figi *internal.OpenFIGI
} }
func NewRecordReader(r io.Reader, f *ofigi.Client) *RecordReader { func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader {
return &RecordReader{ return &RecordReader{
reader: csv.NewReader(r), reader: csv.NewReader(r),
figi: f, figi: f,
@@ -188,7 +187,7 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
} }
} }
func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature { func figiNatureGetter(ctx context.Context, of *internal.OpenFIGI, isin string) func() internal.Nature {
return sync.OnceValue(func() internal.Nature { return sync.OnceValue(func() internal.Nature {
secType, err := of.SecurityTypeByISIN(ctx, isin) secType, err := of.SecurityTypeByISIN(ctx, isin)
if err != nil { if err != nil {
+5 -6
View File
@@ -9,7 +9,6 @@ import (
"time" "time"
"github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/ofigi"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
@@ -222,7 +221,7 @@ func TestRecordReader_ReadRecord_Split(t *testing.T) {
func Test_figiNatureGetter(t *testing.T) { func Test_figiNatureGetter(t *testing.T) {
tests := []struct { tests := []struct {
name string // description of this test case name string // description of this test case
of *ofigi.Client of *internal.OpenFIGI
want internal.Nature want internal.Nature
}{ }{
{ {
@@ -273,7 +272,7 @@ func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req) return f(req)
} }
func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *ofigi.Client { func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *internal.OpenFIGI {
t.Helper() t.Helper()
c := &http.Client{ c := &http.Client{
@@ -288,10 +287,10 @@ func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *ofigi.Cli
}), }),
} }
return ofigi.NewOpenFIGI(c, "") return internal.NewOpenFIGI(c, "")
} }
func NewFigiClientErrorStub(t testing.TB, err error) *ofigi.Client { func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI {
t.Helper() t.Helper()
c := &http.Client{ c := &http.Client{
@@ -301,5 +300,5 @@ func NewFigiClientErrorStub(t testing.TB, err error) *ofigi.Client {
}), }),
} }
return ofigi.NewOpenFIGI(c, "") return internal.NewOpenFIGI(c, "")
} }
-88
View File
@@ -1,88 +0,0 @@
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
}
-151
View File
@@ -1,151 +0,0 @@
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 }