Allow us to generate year over year reports without having to rerun everything from the beginning. Co-authored-by: Natercio Moniz <[email protected]>
This commit was merged in pull request #27.
This commit is contained in:
+60
-19
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -10,22 +11,22 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/nmoniz/any2anexoj/internal/trading212"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var (
|
||||
// TODO: once we support more brokers or exchanges we should make this parameter required and
|
||||
// remove/change default
|
||||
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
|
||||
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
|
||||
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
|
||||
format = pflag.StringP("format", "f", "table", "Output format: table or csv")
|
||||
ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)")
|
||||
// TODO: improve documentation on selectors
|
||||
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
|
||||
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
|
||||
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
|
||||
format = pflag.StringP("format", "f", "table", "Output format: table or csv")
|
||||
ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)")
|
||||
selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors: code, assetCountry")
|
||||
stateFile = pflag.String("state-file", "", "Path to a state file for incremental processing")
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -38,12 +39,19 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// run is the production entry point. It wires the CLI flags to runWithIO
|
||||
// using the process's actual stdin/stdout and installs OS-signal-driven
|
||||
// cancellation so a Ctrl-C cancels in-flight processing.
|
||||
func run(ctx context.Context) error {
|
||||
ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt)
|
||||
defer cancel()
|
||||
return runWithIO(ctx, os.Stdin, os.Stdout)
|
||||
}
|
||||
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
// runWithIO is the testable core of the CLI. It reads broker records from
|
||||
// stdin, writes the formatted report to stdout, and optionally persists
|
||||
// incremental state to the path supplied via --state-file.
|
||||
func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
||||
logLevel := slog.LevelInfo
|
||||
if *debug {
|
||||
logLevel = slog.LevelDebug
|
||||
@@ -58,11 +66,18 @@ func run(ctx context.Context) error {
|
||||
return fmt.Errorf("--language flag is required")
|
||||
}
|
||||
|
||||
reader, err := getReader(*platform, *ofAPIKey)
|
||||
figiClient := ofigi.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, *ofAPIKey)
|
||||
|
||||
reader, err := getReader(*platform, stdin, figiClient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting reader: %w", err)
|
||||
}
|
||||
|
||||
store, err := buildStore(*stateFile, *platform, figiClient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writer := internal.NewAggregatorWriter()
|
||||
|
||||
selector, err := internal.ParseSelectors(*selectors)
|
||||
@@ -70,34 +85,60 @@ func run(ctx context.Context) error {
|
||||
return fmt.Errorf("parsing selectors: %w", err)
|
||||
}
|
||||
|
||||
eg.Go(func() error {
|
||||
return internal.BuildReport(ctx, reader, writer, selector)
|
||||
})
|
||||
|
||||
err = eg.Wait()
|
||||
err = internal.BuildReport(
|
||||
ctx,
|
||||
reader,
|
||||
writer,
|
||||
internal.WithSelector(selector),
|
||||
internal.WithStore(store),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch *format {
|
||||
case "csv":
|
||||
return NewCSVWriter(os.Stdout).Render(writer)
|
||||
return NewCSVWriter(stdout).Render(writer)
|
||||
case "table":
|
||||
loc, err := NewLocalizer(*lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create localizer: %w", err)
|
||||
}
|
||||
NewPrettyPrinter(os.Stdout, loc).Render(writer)
|
||||
NewPrettyPrinter(stdout, loc).Render(writer)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported format %q: must be table or csv", *format)
|
||||
}
|
||||
}
|
||||
|
||||
func getReader(platform string, ofAPIKey string) (internal.RecordReader, error) {
|
||||
// buildStore returns the Store implementation that BuildReport should use.
|
||||
// When --state-file is empty an EphemeralStore is used so behaviour is
|
||||
// identical to pre-persistence runs. Otherwise a JSON-backed FileStore is
|
||||
// returned, wired to a platform-specific RecordSerializer.
|
||||
func buildStore(stateFile, platform string, figi *ofigi.Client) (internal.Store, error) {
|
||||
if stateFile == "" {
|
||||
return internal.EphemeralStore{}, nil
|
||||
}
|
||||
|
||||
var serializer internal.RecordSerializer
|
||||
switch platform {
|
||||
case "trading212":
|
||||
return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}, ofAPIKey)), nil
|
||||
serializer = trading212.NewRecordSerializer(figi)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported platform for state persistence: %s", platform)
|
||||
}
|
||||
|
||||
store, err := internal.NewFileStore(stateFile, platform, serializer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating file store: %w", err)
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func getReader(platform string, r io.Reader, figi *ofigi.Client) (internal.RecordReader, error) {
|
||||
switch platform {
|
||||
case "trading212":
|
||||
return trading212.NewRecordReader(r, figi), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported platform: %s", platform)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// resetFlags puts every pflag-backed variable back to its default so each
|
||||
// test that calls runWithIO sees a clean slate regardless of test ordering
|
||||
// or arguments the previous test set via pflag.Set.
|
||||
func resetFlags(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := pflag.Set("platform", "trading212"); err != nil {
|
||||
t.Fatalf("reset platform flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("language", "en"); err != nil {
|
||||
t.Fatalf("reset language flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("debug", "false"); err != nil {
|
||||
t.Fatalf("reset debug flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("format", "table"); err != nil {
|
||||
t.Fatalf("reset format flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("open-figi-api-key", ""); err != nil {
|
||||
t.Fatalf("reset open-figi-api-key flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("selectors", ""); err != nil {
|
||||
t.Fatalf("reset selectors flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("state-file", ""); err != nil {
|
||||
t.Fatalf("reset state-file flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// trading212SampleCSV is a minimal Trading212 export with a header row and
|
||||
// one market buy + one matching market sell so BuildReport reaches EOF and
|
||||
// exercises the store.Save path. The line format mirrors the fixtures in
|
||||
// internal/trading212/record_test.go (20 columns).
|
||||
const trading212SampleCSV = `Action,Time,ISIN,Ticker,Name,Notes,Quantity,Price,Price currency,Exchange rate,Result,Result currency,Charges,Charges currency,Stamp duty,Stamp duty currency,Conversion fee,Conversion fee currency,French transaction tax,French transaction tax currency
|
||||
Market buy,2025-07-03 10:44:29,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,
|
||||
Market sell,2025-08-04 11:45:30,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"
|
||||
`
|
||||
|
||||
// runWithStdin runs runWithIO against the supplied stdin payload.
|
||||
func runWithStdin(t *testing.T, stdin string, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
if err := runWithIO(t.Context(), strings.NewReader(stdin), stdout); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestRunWithIO_StateFileCreated verifies that running the CLI with
|
||||
// --state-file produces a state file on disk after a successful EOF.
|
||||
func TestRunWithIO_StateFileCreated(t *testing.T) {
|
||||
resetFlags(t)
|
||||
t.Cleanup(func() { resetFlags(t) })
|
||||
|
||||
dir := t.TempDir()
|
||||
statePath := filepath.Join(dir, "state.json")
|
||||
|
||||
if err := pflag.Set("state-file", statePath); err != nil {
|
||||
t.Fatalf("set state-file flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("format", "csv"); err != nil {
|
||||
t.Fatalf("set format flag: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
|
||||
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
info, err := os.Stat(statePath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected state file at %s but stat returned error: %v", statePath, err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatalf("state file at %s is empty", statePath)
|
||||
}
|
||||
|
||||
// State file must look like JSON with the expected version field.
|
||||
body, err := os.ReadFile(statePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read state file: %v", err)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"version"`)) {
|
||||
t.Errorf("state file missing version field, got: %s", body)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"trading212"`)) {
|
||||
t.Errorf("state file missing trading212 platform, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWithIO_NoStateFileByDefault verifies that omitting --state-file
|
||||
// behaves exactly like the pre-persistence CLI: nothing is written to disk
|
||||
// and the report still renders.
|
||||
func TestRunWithIO_NoStateFileByDefault(t *testing.T) {
|
||||
resetFlags(t)
|
||||
t.Cleanup(func() { resetFlags(t) })
|
||||
|
||||
// Use a temp working directory so any accidental file write would
|
||||
// show up clearly via t.TempDir's cleanup listing.
|
||||
dir := t.TempDir()
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
|
||||
if err := pflag.Set("format", "csv"); err != nil {
|
||||
t.Fatalf("set format flag: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
|
||||
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read tmp dir: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
t.Errorf("unexpected file written without --state-file: %s", e.Name())
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
t.Errorf("expected non-empty csv output on stdout")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWithIO_UnsupportedPlatformForPersistence verifies that using
|
||||
// --state-file with an unknown platform surfaces a clear error rather than
|
||||
// silently falling back to EphemeralStore.
|
||||
func TestRunWithIO_UnsupportedPlatformForPersistence(t *testing.T) {
|
||||
resetFlags(t)
|
||||
t.Cleanup(func() { resetFlags(t) })
|
||||
|
||||
if err := pflag.Set("state-file", filepath.Join(t.TempDir(), "state.json")); err != nil {
|
||||
t.Fatalf("set state-file flag: %v", err)
|
||||
}
|
||||
// Currently only trading212 is wired through buildStore, but the
|
||||
// reader switch also only supports trading212, so the reader error
|
||||
// fires first. Either error is acceptable; we just need a clear
|
||||
// failure message.
|
||||
if err := pflag.Set("platform", "unknown-broker"); err != nil {
|
||||
t.Fatalf("set platform flag: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := runWithStdin(t, trading212SampleCSV, &stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error for unsupported platform")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "platform") {
|
||||
t.Errorf("expected error to mention platform, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,7 +12,7 @@ import (
|
||||
func TestPrettyPrinter_Render(t *testing.T) {
|
||||
// Create test data
|
||||
aw := internal.NewAggregatorWriter()
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
// Add some sample report items
|
||||
err := aw.Write(ctx, internal.ReportItem{
|
||||
|
||||
Reference in New Issue
Block a user