Author SHA1 Message Date
renovate-bot Bot 4ffa03d632 Update module github.com/jedib0t/go-pretty/v6 to v6.9.1
Generate check / check-changes (pull_request) Successful in 3s
Generate check / verify-generate (pull_request) Skipped
Quality / check-changes (pull_request) Successful in 2s
Quality / static-checks (pull_request) Successful in 55s
Quality / run-tests (pull_request) Successful in 53s
2026-11-13 13:36:30 +00:00
renovate-bot Bot ede5dfafe2 Update module github.com/nicksnyder/go-i18n/v2 to v2.7.0 (#42)
Badges / coveralls (push) Successful in 1m1s
Reviewed-on: #42
Co-authored-by: Renovate Bot <[email protected]>
2026-10-30 17:09:12 +00:00
renovate-bot Bot ae0e77cecb Update module github.com/shopspring/decimal to v1.6.0 (#40)
Badges / coveralls (push) Successful in 1m23s
Reviewed-on: #40
Co-authored-by: Renovate Bot <[email protected]>
2026-10-30 07:13:01 +00:00
renovate-bot Bot 76502bc8d4 Update module golang.org/x/time to v0.16.0 (#43)
Badges / coveralls (push) Successful in 1m2s
Reviewed-on: #43
Co-authored-by: Renovate Bot <[email protected]>
2026-10-29 15:46:53 +00:00
renovate-bot Bot 6a399b7c56 Update module golang.org/x/text to v0.43.0 (#41)
Badges / coveralls (push) Successful in 1m1s
Reviewed-on: #41
Co-authored-by: Renovate Bot <[email protected]>
2026-10-28 17:18:34 +00:00
renovate-bot Bot ff791955d4 Update module github.com/nicksnyder/go-i18n/v2 to v2.6.1 (#37)
Badges / coveralls (push) Successful in 58s
Reviewed-on: #37
Co-authored-by: Renovate Bot <[email protected]>
2026-10-27 23:00:10 +00:00
renovate-bot Bot 5011174bdd Update module github.com/jedib0t/go-pretty/v6 to v6.8.3 (#38)
Badges / coveralls (push) Successful in 1m1s
Co-authored-by: Renovate Bot <[email protected]>
2026-10-27 22:55:50 +00:00
natercio c42a852938 Run go mod tidy after renovate-bot updates deps (#39)
Badges / coveralls (push) Successful in 27s
Reviewed-on: #39
Co-authored-by: Natercio Moniz <[email protected]>
2026-10-27 22:49:07 +00:00
natercio f35cf1d624 Setup renovate (#36)
Badges / coveralls (push) Successful in 29s
Reviewed-on: #36
Co-authored-by: Natercio Moniz <[email protected]>
2026-10-27 22:32:38 +00:00
natercio 16ae9975ee Handle Spin-off events as buys (#35)
Badges / coveralls (push) Successful in 1m1s
Reviewed-on: #35
Co-authored-by: Natercio Moniz <[email protected]>
2026-10-27 22:08:18 +00:00
natercio 41b287c804 Include latest and earliest record times in state (#34)
Badges / coveralls (push) Successful in 1m1s
Reviewed-on: #34
Co-authored-by: Natercio Moniz <[email protected]>
2026-10-12 23:09:39 +01:00
natercio 47f38fce2b Implement HTML writer (#33)
Badges / coveralls (push) Successful in 1m1s
Reviewed-on: #33
Co-authored-by: Natercio Moniz <[email protected]>
2026-10-05 16:25:21 +01:00
natercio 6c989c338c Skip spin off actions (#32)
Badges / coveralls (push) Successful in 30s
Reviewed-on: #32
Co-authored-by: Natercio Moniz <[email protected]>
2026-09-06 21:44:21 +01:00
natercio ccef829832 Hanlde trading212 inconsistent date formats (#31)
Badges / coveralls (push) Successful in 30s
Reviewed-on: #31
Co-authored-by: Natercio Moniz <[email protected]>
2026-09-06 18:44:22 +01:00
natercio 3c404699e5 Refactor FileStore to JSONStore (#30)
Badges / coveralls (push) Successful in 31s
Reviewed-on: #30
Co-authored-by: Natercio Moniz <[email protected]>
2026-09-06 18:42:58 +01:00
natercio e5961c0dff Update readme with year over year instructions (#29)
Badges / coveralls (push) Successful in 1m3s
Reviewed-on: #29
Co-authored-by: Natercio Moniz <[email protected]>
2026-09-06 16:17:17 +01:00
natercio 9eb1a781e7 Add several quality assurance jobs (#28)
Badges / coveralls (push) Successful in 1m33s
Reviewed-on: #28
Co-authored-by: Natercio Moniz <[email protected]>
2026-08-05 10:01:30 +01:00
natercio 9bd4230ff1 Internal state persistence (#27)
Badges / coveralls (push) Successful in 1m4s
Allow us to generate year over year reports without having to rerun everything from the beginning.

Co-authored-by: Natercio Moniz <[email protected]>
2026-08-03 00:33:06 +01:00
natercio 1ce8561782 Fix selector behaviour (#26)
Badges / coveralls (push) Successful in 1m4s
Co-authored-by: Natercio Moniz <[email protected]>
Co-committed-by: Natercio Moniz <[email protected]>
2026-07-11 15:24:54 +01:00
natercio 8b2c4aa6fe Merge pull request 'Several improvements and bug fixes for 2025 tax return' (#25) from fix-bugs-for-2025-report into main
Badges / coveralls (push) Successful in 14s
Reviewed-on: #25
2026-05-17 09:47:28 +01:00
natercio 22a0204fa2 fix trading212 record test
Generate check / check-changes (pull_request) Successful in 4s
Quality / check-changes (pull_request) Successful in 3s
Generate check / verify-generate (pull_request) Successful in 11s
Quality / run-tests (pull_request) Successful in 13s
2026-05-17 09:45:53 +01:00
natercio a4237aa00c add a csv printer
Generate check / check-changes (pull_request) Successful in 4s
Quality / check-changes (pull_request) Successful in 3s
Generate check / verify-generate (pull_request) Successful in 11s
Quality / run-tests (pull_request) Failing after 10s
2026-05-16 15:38:19 +01:00
natercio 7cc5d1cf75 handle stock split
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / verify-generate (pull_request) Successful in 16s
Quality / run-tests (pull_request) Failing after 10s
2026-05-16 15:34:24 +01:00
natercio 5cdccfcdb1 rename side to kind 2026-05-16 14:21:14 +01:00
natercio d371aca767 apply selectors only on sells for performance
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Successful in 13s
2026-05-16 11:58:35 +01:00
natercio 24c2814eef improved logging for debug 2026-05-16 11:44:35 +01:00
natercio c110a2cc70 fix ASD to ADR 2026-05-16 10:48:47 +01:00
natercio 1c29f52cce support REIT security type 2026-05-16 10:43:52 +01:00
natercio b0d91e7eee support openfigi api key 2026-05-16 10:36:14 +01:00
natercio 91885b1993 support ASD 2026-05-16 09:33:51 +01:00
natercio 840919e8aa Merge pull request 'Support filtering by nature and asset country' (#24) from filters into main
Badges / coveralls (push) Successful in 33s
Reviewed-on: #24
2026-05-16 09:12:24 +01:00
natercio adcd192cb9 support some filtering
Generate check / check-changes (pull_request) Successful in 28s
Quality / check-changes (pull_request) Successful in 3s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Successful in 58s
2026-05-16 09:09:39 +01:00
natercio 4626f08b9c Merge pull request 'localization' (#23) from localization into main
Badges / coveralls (push) Successful in 55s
Reviewed-on: #23
2025-12-04 17:00:37 +00:00
natercio 0d97432c6c improved error handling on translation
Generate check / check-changes (pull_request) Successful in 18s
Quality / check-changes (pull_request) Successful in 17s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Successful in 30s
2025-12-04 16:59:20 +00:00
natercio 10e1e99683 improve test coverage 2025-12-04 16:56:34 +00:00
natercio 38db50b879 add and fix tests
Generate check / check-changes (pull_request) Successful in 18s
Quality / check-changes (pull_request) Successful in 18s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Successful in 1m7s
2025-12-04 16:23:26 +00:00
natercio 21147954cb support translations
Generate check / check-changes (pull_request) Failing after 0s
Quality / check-changes (pull_request) Successful in 19s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Failing after 1m3s
2025-12-04 16:03:10 +00:00
natercio b12c519fdb separate print logic from write logic 2025-12-04 13:04:30 +00:00
natercio f651ce8597 Merge pull request 'fix misspelling of different' (#22) from fix-misspelling into main
Badges / coveralls (push) Successful in 12s
Reviewed-on: #22
2025-11-26 11:17:42 +00:00
natercio a19b02f1f6 fix misspelling of different
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 4s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Successful in 9s
2025-11-26 10:50:13 +00:00
natercio a079ea8ae5 Merge pull request 'set coveralls badge link to point to main branch' (#21) from fix-badge-branch into main
Badges / coveralls (push) Successful in 12s
Reviewed-on: #21
2025-11-26 10:47:55 +00:00
natercio 5a0fb8b6aa set coveralls badge link to point to main branch
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 4s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Has been skipped
2025-11-26 10:47:24 +00:00
natercio f2bfefcea5 Merge pull request 'update coveralls badge when main is updated' (#20) from update-coveralls-badge into main
Badges / coveralls (push) Successful in 25s
Reviewed-on: #20
2025-11-26 10:34:40 +00:00
natercio d415fcd752 separate coveralls and refine workflow triggers
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / verify-generate (pull_request) Has been skipped
Quality / run-tests (pull_request) Has been skipped
2025-11-26 10:32:27 +00:00
natercio 950143e17d update coveralls badge when main is updated
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Has been skipped
Generate check / check-changes (push) Successful in 4s
2025-11-26 10:19:43 +00:00
natercio 066b5b76a8 Merge pull request 'Present values rounded to cents' (#19) from rounding-to-cents into main
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #19
2025-11-26 10:08:33 +00:00
natercio 2a3f13e91a remove readme image width constraint
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 11s
Generate check / check-changes (push) Successful in 3s
2025-11-26 10:03:35 +00:00
natercio 0b6b35e736 update readme screenshot and add section about rounding
Generate check / check-changes (push) Successful in 4s
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 14s
2025-11-26 09:56:49 +00:00
natercio 5060fca7be round to decimals and improve presentation
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (push) Successful in 3s
2025-11-25 15:14:49 +00:00
natercio 4a2884a0df Merge pull request 'Cache calls to SecurityTypeByISIN' (#18) from cache-figi-responses into main
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (push) Successful in 4s
Reviewed-on: #18
2025-11-25 13:52:05 +00:00
natercio 64bbf8d129 error on empty securityType and more efficient locks
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 14s
Generate check / check-changes (push) Successful in 3s
2025-11-25 13:50:17 +00:00
natercio 57ee768006 cache calls to SecurityTypeByISIN
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 14s
Generate check / check-changes (push) Successful in 4s
This avoids unecessary repeated calls to OpenFIGI api
2025-11-25 13:05:44 +00:00
natercio 0d3e3df9e7 Merge pull request 'Report includes the type/nature of row' (#17) from record-type into main
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #17
2025-11-24 16:29:44 +00:00
natercio 5f13ebaf6a test expects Nature method call
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Successful in 9s
Quality / tests (pull_request) Successful in 58s
Generate check / check-changes (push) Successful in 3s
2025-11-24 16:27:44 +00:00
natercio 70466b7886 fix typos and copy paste test names
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Successful in 8s
Quality / tests (pull_request) Failing after 42s
Generate check / check-changes (push) Successful in 4s
2025-11-24 16:23:57 +00:00
natercio bd101ce46a fix critical tax calculation 2025-11-24 16:18:02 +00:00
natercio 93f1dab3d2 fix isin in tests
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 4s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Successful in 8s
Quality / tests (pull_request) Failing after 42s
Generate check / check-changes (push) Successful in 4s
2025-11-24 16:15:21 +00:00
natercio c323047175 report show nature
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 4s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Successful in 28s
Quality / tests (pull_request) Failing after 41s
Generate check / check-changes (push) Successful in 4s
2025-11-24 16:03:28 +00:00
natercio 8c784f3b74 check isin format and enforce rate limit 2025-11-24 16:02:27 +00:00
natercio a1ea13ff2f add string method to the Nature type 2025-11-24 15:55:30 +00:00
natercio 6b5552b559 add Nature method to Record type 2025-11-24 15:55:30 +00:00
natercio 23614d51db add OpenFIGI adaptar with tests 2025-11-24 12:17:58 +00:00
natercio ef0a4476a7 add Nature type 2025-11-24 12:17:32 +00:00
natercio b4b12ad625 add new Nature method to Record interface 2025-11-21 13:06:26 +00:00
natercio 1106705eb2 Merge pull request 'add badge to readme' (#16) from coveralls-badge into main
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #16
2025-11-20 23:52:18 +00:00
natercio f716c2e897 add badge to readme
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 4s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Has been skipped
Generate check / check-changes (push) Successful in 4s
2025-11-20 23:50:37 +00:00
natercio ef350b2659 Merge pull request 'add coveralls steps to the tests' (#15) from coveralls-badge into main
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (push) Successful in 3s
Reviewed-on: #15
2025-11-20 20:01:30 +00:00
natercio 914ead1681 fix issues with grepping output
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 16s
Generate check / check-changes (push) Successful in 4s
2025-11-20 19:59:45 +00:00
natercio c363652f49 ignore generated files in coverage report
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Failing after 6s
Generate check / check-changes (push) Successful in 3s
2025-11-20 19:53:25 +00:00
natercio a347443c81 add comments to side methods
Generate check / check-changes (pull_request) Successful in 3s
Quality / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Successful in 49s
Generate check / check-changes (push) Successful in 3s
2025-11-20 19:41:12 +00:00
natercio 89bcd15b17 add coveralls steps to the tests
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 2s
Quality / check-changes (pull_request) Successful in 2s
Generate check / check-generate (pull_request) Has been skipped
Quality / tests (pull_request) Has been skipped
Generate check / check-changes (push) Successful in 3s
2025-11-20 19:32:31 +00:00
natercio 9ba5116c03 Merge pull request 'Add go report badge' (#14) from go-report-badge into main
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (push) Successful in 3s
Reviewed-on: #14
2025-11-19 11:41:54 +00:00
natercio 1c3fd0397a fix typos
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 3s
Tests / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Has been skipped
Tests / tests (pull_request) Successful in 7s
2025-11-19 11:40:09 +00:00
natercio 961f0eed38 add go report card 2025-11-19 11:38:46 +00:00
natercio 290593a9aa Merge pull request 'Improve presentation and correctness' (#13) from improve-presentation into main
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (push) Successful in 3s
Reviewed-on: #13
2025-11-18 16:22:57 +00:00
natercio f49377a6dd improve description and include image
Generate check / check-generate (push) Has been skipped
Generate check / check-changes (pull_request) Successful in 3s
Tests / check-changes (pull_request) Successful in 3s
Generate check / check-generate (pull_request) Successful in 24s
Tests / tests (pull_request) Successful in 20s
Generate check / check-changes (push) Successful in 3s
2025-11-18 16:20:28 +00:00
natercio 91f6bd1a3e remove the isin from the code and print placeholder instead 2025-11-18 16:13:26 +00:00
natercio d097b01288 print a pretty table with correct country coder 2025-11-18 16:07:41 +00:00
natercio 05a981b3a0 Merge pull request 'use shopspring/decimal library everywhere' (#12) from fix-zero-entries into main
Generate check / check-changes (push) Successful in 3s
Generate check / check-generate (push) Has been skipped
Reviewed-on: #12
2025-11-16 23:25:06 +00:00
natercio f6e870d7b7 updated gitea workflows conditional logic
Generate check / check-changes (pull_request) Successful in 3s
Tests / check-changes (pull_request) Successful in 3s
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Successful in 22s
Tests / tests (pull_request) Successful in 12s
Generate check / check-changes (push) Successful in 3s
2025-11-16 23:23:06 +00:00
natercio a6d56d7441 use shopspring/decimal library everywhere
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Tests / tests (pull_request) Has been skipped
2025-11-16 23:03:47 +00:00
natercio edc1628674 Merge pull request 'fix licensing' (#11) from fix-licenses into main
Generate check / check-generate (push) Has been skipped
Reviewed-on: #11
2025-11-14 16:37:52 +00:00
natercio 7709023ef4 only run tests if there are changes to go files
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Tests / tests (pull_request) Has been skipped
2025-11-14 16:37:34 +00:00
natercio 9ae1e959e9 fix licensing
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Tests / tests (pull_request) Successful in 5s
2025-11-14 16:34:59 +00:00
natercio 67d77e35a0 Merge pull request 'renamed broker2anexoj-cli to any2anexoj-cli' (#9) from fix-readme into main
Generate check / check-generate (push) Has been skipped
Reviewed-on: #9
2025-11-14 15:30:16 +00:00
natercio ad0bfc6979 renamed broker2anexoj-cli to any2anexoj-cli
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Tests / tests (pull_request) Successful in 6s
2025-11-14 15:29:19 +00:00
natercio 7dbbfc3702 Merge pull request 'always run the job but skip when irrelevant' (#10) from check-gen-condition into main
Generate check / check-generate (push) Has been skipped
Reviewed-on: #10
2025-11-14 15:28:10 +00:00
natercio 5dc9601e28 always run the job but skip when irrelevant
Generate check / check-generate (push) Has been skipped
Generate check / check-generate (pull_request) Has been skipped
Tests / tests (pull_request) Successful in 6s
2025-11-14 15:27:26 +00:00
natercio 4b520f6164 Merge pull request 'Better usability' (#8) from better-usability into main
Generate check / check-generate (push) Successful in 9s
Reviewed-on: #8
2025-11-14 15:01:06 +00:00
natercio 60d6f26162 rename supportedPlatforms
Generate check / check-generate (pull_request) Successful in 23s
Tests / tests (pull_request) Successful in 11s
2025-11-14 14:59:04 +00:00
natercio 14cfe33f95 update readme and platform param 2025-11-14 14:57:10 +00:00
natercio f356d2f7e1 rename module to match github repo 2025-11-14 14:14:32 +00:00
natercio e4088e4aec read from stdin and write logs to stderr 2025-11-14 09:52:07 +00:00
natercio bad41b431d Merge pull request 'Handle context cancelation' (#6) from context-everywhere into main
Generate check / check-generate (push) Successful in 7s
Reviewed-on: applications/broker2anexoj#6
2025-11-14 09:19:37 +00:00
natercio 791306acf1 regenerate mocks with new methods
Generate check / check-generate (pull_request) Successful in 13s
Tests / tests (pull_request) Successful in 6s
2025-11-14 09:17:06 +00:00
natercio f3d0f5d71a handle context cancelation 2025-11-14 09:17:06 +00:00
natercio 54fced39aa Merge pull request 'setup go before in check-generate gitea job' (#7) from fix-check-gen-action into main
Reviewed-on: applications/broker2anexoj#7
2025-11-14 09:16:12 +00:00
natercio c477023041 setup go before running go generate
Tests / tests (pull_request) Successful in 4s
2025-11-14 09:14:10 +00:00
natercio 6b4373c889 Merge pull request 'Generated changes must not be tampered' (#5) from action-check-generated-code into main
Reviewed-on: applications/broker2anexoj#5
2025-11-13 15:59:18 +00:00
natercio 4b9a91b98e check if generated changes were not tampered 2025-11-13 15:59:18 +00:00
natercio ecdc279de2 Merge pull request 'isolate record reading and writing from processing' (#4) from refactor-report-logic into main
Reviewed-on: applications/broker2anexoj#4
2025-11-13 14:22:35 +00:00
natercio 70bd8622de isolate record reading and writing from processing
Tests / tests (pull_request) Successful in 17s
2025-11-13 14:07:08 +00:00
natercio d3fa025a92 Merge pull request 'Peek method for the RecordQueue type' (#3) from peeking-record-queue into main
Reviewed-on: applications/broker2anexoj#3
2025-11-13 13:59:25 +00:00
natercio 93689754be implement and test the Peek for the RecordQueue type
Tests / tests (pull_request) Successful in 6s
2025-11-13 13:58:07 +00:00
natercio 7800b1163b Merge pull request 'record-support-taxes-and-fees' (#2) from record-support-taxes-and-fees into main
Reviewed-on: applications/broker2anexoj#2
2025-11-13 13:51:54 +00:00
natercio 7450c0d571 fix unused imports
Tests / tests (pull_request) Successful in 17s
2025-11-13 13:47:41 +00:00
68 changed files with 6331 additions and 719 deletions

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
name: Badges
on:
push:
branches:
- main
jobs:
coveralls:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.25
- name: Run Unit tests with coverage
run: |
go test -covermode atomic -coverprofile=coverage.out ./...
grep -v -E "(main|_gen).go" coverage.out > coverage.filtered.out
mv coverage.filtered.out coverage.out
- name: Install goveralls
run: go install github.com/mattn/[email protected]
- name: Send coverage
env:
COVERALLS_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN}}
run: goveralls -coverprofile=coverage.out -service=github
-43
View File
@@ -1,43 +0,0 @@
name: Claude Assistant
on:
# Trigger on issue comments (works on both issues and pull requests in Gitea)
issue_comment:
types: [created]
# Trigger on issues being opened or assigned
issues:
types: [opened, assigned]
# Note: pull_request_review_comment has limited support in Gitea
# Use issue_comment instead which covers PR comments
jobs:
claude-assistant:
# Basic trigger detection - check for @claude in comments or issue body
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || github.event.action == 'assigned'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
# Note: Gitea Actions may not require id-token: write for basic functionality
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Assistant
uses: markwylde/[email protected]
with:
gitea_token: ${{ secrets.GITEA_TOKEN }} # Use standard workflow token
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"
trigger_phrase: "@claude"
# Optional: Customize for Gitea environment
custom_instructions: |
You are working in a Gitea environment. Be aware that:
- Some GitHub Actions features may behave differently
- Focus on core functionality and avoid advanced GitHub-specific features
- Use standard git operations when possible
+56
View File
@@ -0,0 +1,56 @@
name: Generate check
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
has_gen_changes: ${{ steps.check.outputs.has_gen_changes }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check for generated file changes
id: check
run: |
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '_gen\.go$|/generate\.go$'; then
echo "has_gen_changes=true" >> $GITHUB_OUTPUT
else
echo "has_gen_changes=false" >> $GITHUB_OUTPUT
fi
verify-generate:
runs-on: ubuntu-latest
needs: check-changes
if: needs.check-changes.outputs.has_gen_changes == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.25
- name: Save pre-generate git state
run: git status --porcelain > pre-generate.txt
- name: Run go generate
run: go generate ./...
- name: Save post-generate git state
run: git status --porcelain > post-generate.txt
- name: Check for changes
run: |
if ! diff pre-generate.txt post-generate.txt | grep .; then
echo "No files changed by go generate"
else
echo "go generate produced changes; commit those first" >&2
exit 1
fi
+74
View File
@@ -0,0 +1,74 @@
name: Quality
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
has_go_changes: ${{ steps.check.outputs.has_go_changes }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check for Go changes
id: check
run: |
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.go$|go\.(mod|sum)$'; then
echo "has_go_changes=true" >> $GITHUB_OUTPUT
else
echo "has_go_changes=false" >> $GITHUB_OUTPUT
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:
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 Unit tests
run: |
go test -race -covermode atomic -coverprofile=coverage.out ./...
-20
View File
@@ -1,20 +0,0 @@
name: Tests
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.25
- name: Run tests
run: go test -v ./...
-55
View File
@@ -618,58 +618,3 @@ 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.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+14
View File
@@ -0,0 +1,14 @@
Copyright (C) 2025 Natercio Moniz
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
+47 -7
View File
@@ -1,23 +1,63 @@
# broker2anexoj # any2anexoj
This tool converts the statements from brokers and exchanges into a format compatible with the Portuguese IRS form: [Mod_3_anexo_j](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/modelos_formularios/irs/Documents/Mod_3_anexo_J.pdf) [![Coverage Status](https://coveralls.io/repos/github/nmoniz/any2anexoj/badge.svg?branch=main)](https://coveralls.io/github/nmoniz/any2anexoj?branch=main)
<p align="center">
<img src="https://i.ibb.co/0yRtwq2C/0-FBA40-FD-D97-A-4-AFB-8618-49582-DB98-F3-C.png" alt="Screenshot" border="0">
</p>
This tool converts the statements from known brokers and exchanges into a format compatible with section 9 from the Portuguese IRS form: [Mod_3_anexo_j](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/modelos_formularios/irs/Documents/Mod_3_anexo_J.pdf)
> [!WARNING] > [!WARNING]
> Although I made significant efforts to ensure the correctness of the calculations you should verify any outputs produced by this tool on your own or with a certified accountant. > Although I made significant efforts to ensure the correctness of the calculations you should verify any outputs produced by this tool on your own or with a certified accountant.
> [!NOTE]
> This tool is in early stages of development. Use at your own risk!
## Install ## Install
```bash ```bash
go install git.naterciomoniz.net/applications/broker2anexoj@latest go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
``` ```
## Usage ## Usage
```bash ```bash
broker2anexoj cat statement.csv | any2anexoj-cli --platform=trading212
``` ```
`trading212` is currently the only supported platform. The output format is selected with `-f`/`--format`:
- `table` (default): pretty terminal table
- `csv`: the Anexo J CSV schema
- `html`: a complete, standalone Bootstrap-styled HTML report — a `<!DOCTYPE html>` document with the Anexo J table embedded and the official Bootstrap 5.3.3 stylesheet inlined via `go:embed`, so the report renders fully offline
The `table` and `html` formats are translated according to `--language`/`-l` (`pt` by default, `en` available). Other flags: `--selectors` filters which entries are processed (`code`, `assetCountry`), `--open-figi-api-key` raises OpenFIGI rate limits for faster report generation, and `--debug` enables debug logging.
### Incremental Reports
For year over year reporting, pass `--state-file <path>` to keep the FIFO buy-queue between runs. The typical workflow is:
1. Generate the first year Anexo J report from the first year broker statement and keep the state file.
2. Next year, generate the next report from the second year broker statement **only**, using the same state file.
```bash
# first year
cat 2024-statement.csv | any2anexoj-cli --platform=trading212 --state-file=trading212-state.json
# following year
cat 2025-statement.csv | any2anexoj-cli --platform=trading212 --state-file=trading212-state.json
```
The first run behaves like a normal run and creates the state file.
On later runs the tool loads the previously saved buy queues per symbol (including partially filled lots), processes only the new transactions from the input, and writes the updated state.
The state file is a JSON document.
> [!IMPORTANT]
> Each new input must contain **only** the new transactions, not the previous ones.
> The state file already holds the unmatched buy lots from earlier runs, so including previous trades again will cause them to be processed twice and may fail with insufficient bought volume.
State is only persisted once the input reaches EOF, so a crash or `Ctrl-C` before the end of input will discard progress from that run.
## Rounding
All Euro values are rounded to cents (2 decimal places) but internal calculations use the statement values with full precision.
There are no explicit rules or details about how to round Euro values in Anexo J.
This application rounds according to `Portaria n.º 1180/2001, art. 2.º, alínea c) e d)` (Ministerial Order / Government Order) examples, which imply we should round to the 2nd decimal place by rounding up (ceiling) or down (floor) depending on whether the third decimal place is ≥ 5 or < 5, respectively.
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/ofigi"
"github.com/nmoniz/any2anexoj/internal/trading212"
"github.com/nmoniz/any2anexoj/internal/writing"
"github.com/nmoniz/any2anexoj/internal/writing/csv"
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
"github.com/nmoniz/any2anexoj/internal/writing/table"
"github.com/spf13/pflag"
"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, csv or html")
ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better 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() {
pflag.Parse()
err := run(context.Background())
if err != nil {
slog.Error("found a fatal issue", slog.Any("err", err))
os.Exit(1)
}
}
// 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)
}
// 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
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})))
if platform == nil || len(*platform) == 0 {
return fmt.Errorf("--platform flag is required")
}
if lang == nil || len(*lang) == 0 {
return fmt.Errorf("--language flag is required")
}
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)
if err != nil {
return fmt.Errorf("parsing selectors: %w", err)
}
err = internal.BuildReport(
ctx,
reader,
writer,
internal.WithSelector(selector),
internal.WithStore(store),
)
if err != nil {
return err
}
switch *format {
case "csv":
return csv.NewWriter(stdout).Render(writer)
case "table":
loc, err := writing.NewLocalizer(*lang)
if err != nil {
return fmt.Errorf("create localizer: %w", err)
}
table.NewPrinter(stdout, loc).Render(writer)
return nil
case "html":
loc, err := writing.NewLocalizer(*lang)
if err != nil {
return fmt.Errorf("create localizer: %w", err)
}
return htmlwriter.NewWriter(stdout, loc, *lang).Render(writer)
default:
return fmt.Errorf("unsupported format %q: must be table, csv or html", *format)
}
}
// 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 JSONStore 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":
serializer = trading212.NewRecordSerializer(figi)
default:
return nil, fmt.Errorf("unsupported platform for state persistence: %s", platform)
}
store, err := internal.NewJSONStore(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)
}
}
+24 -3
View File
@@ -1,5 +1,26 @@
module git.naterciomoniz.net/applications/broker2anexoj module github.com/nmoniz/any2anexoj
go 1.25.3 go 1.26.4
require go.uber.org/mock v0.6.0 require (
github.com/biter777/countries v1.7.5
github.com/jedib0t/go-pretty/v6 v6.9.1
github.com/shopspring/decimal v1.6.0
github.com/spf13/pflag v1.0.10
go.uber.org/mock v0.6.0
golang.org/x/time v0.16.0
)
require golang.org/x/sync v0.24.0 // indirect
require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/nicksnyder/go-i18n/v2 v2.7.0
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.43.0
golang.org/x/tools v0.51.0 // indirect
)
tool go.uber.org/mock/mockgen
+41
View File
@@ -1,2 +1,43 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.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/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jedib0t/go-pretty/v6 v6.9.1 h1:BCm4yFkewwPNfQebPACU6O6v8YCZKEGe9RVjuqQwPCI=
github.com/jedib0t/go-pretty/v6 v6.9.1/go.mod h1:rDsMESQ3QyK0VMr1aeq/BErQLPk0tJ+ORkcjMMpnjeg=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/nicksnyder/go-i18n/v2 v2.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0=
github.com/nicksnyder/go-i18n/v2 v2.7.0/go.mod h1:ZMYiWg8ndQSGNk4zbzbyJjrrVjQjuQ8pERFrnBik+c8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/shopspring/decimal v1.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+78
View File
@@ -0,0 +1,78 @@
package internal
import (
"context"
"iter"
"sync"
"github.com/shopspring/decimal"
)
// AggregatorWriter tracks ReportItem totals.
type AggregatorWriter struct {
mu sync.RWMutex
items []ReportItem
totalEarned decimal.Decimal
totalSpent decimal.Decimal
totalFees decimal.Decimal
totalTaxes decimal.Decimal
}
func NewAggregatorWriter() *AggregatorWriter {
return &AggregatorWriter{}
}
func (aw *AggregatorWriter) Write(_ context.Context, ri ReportItem) error {
aw.mu.Lock()
defer aw.mu.Unlock()
aw.items = append(aw.items, ri)
aw.totalEarned = aw.totalEarned.Add(ri.SellValue.Round(2))
aw.totalSpent = aw.totalSpent.Add(ri.BuyValue.Round(2))
aw.totalFees = aw.totalFees.Add(ri.Fees.Round(2))
aw.totalTaxes = aw.totalTaxes.Add(ri.Taxes.Round(2))
return nil
}
func (aw *AggregatorWriter) Iter() iter.Seq[ReportItem] {
aw.mu.RLock()
itemsCopy := make([]ReportItem, len(aw.items))
copy(itemsCopy, aw.items)
aw.mu.RUnlock()
return func(yield func(ReportItem) bool) {
for _, ri := range itemsCopy {
if !yield(ri) {
return
}
}
}
}
func (aw *AggregatorWriter) TotalEarned() decimal.Decimal {
aw.mu.RLock()
defer aw.mu.RUnlock()
return aw.totalEarned
}
func (aw *AggregatorWriter) TotalSpent() decimal.Decimal {
aw.mu.RLock()
defer aw.mu.RUnlock()
return aw.totalSpent
}
func (aw *AggregatorWriter) TotalFees() decimal.Decimal {
aw.mu.RLock()
defer aw.mu.RUnlock()
return aw.totalFees
}
func (aw *AggregatorWriter) TotalTaxes() decimal.Decimal {
aw.mu.RLock()
defer aw.mu.RUnlock()
return aw.totalTaxes
}
+287
View File
@@ -0,0 +1,287 @@
package internal_test
import (
"sync"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
func TestAggregatorWriter_Write(t *testing.T) {
tests := []struct {
name string
items []internal.ReportItem
wantEarned decimal.Decimal
wantSpent decimal.Decimal
wantFees decimal.Decimal
wantTaxes decimal.Decimal
}{
{
name: "single write updates all totals",
items: []internal.ReportItem{
{
Symbol: "AAPL",
BuyValue: decimal.NewFromFloat(100.50),
SellValue: decimal.NewFromFloat(150.75),
Fees: decimal.NewFromFloat(2.50),
Taxes: decimal.NewFromFloat(5.25),
BuyTimestamp: time.Now(),
SellTimestamp: time.Now(),
},
},
wantEarned: decimal.NewFromFloat(150.75),
wantSpent: decimal.NewFromFloat(100.50),
wantFees: decimal.NewFromFloat(2.50),
wantTaxes: decimal.NewFromFloat(5.25),
},
{
name: "multiple writes accumulate totals",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(100.00),
SellValue: decimal.NewFromFloat(120.00),
Fees: decimal.NewFromFloat(1.00),
Taxes: decimal.NewFromFloat(2.00),
},
{
BuyValue: decimal.NewFromFloat(200.00),
SellValue: decimal.NewFromFloat(250.00),
Fees: decimal.NewFromFloat(3.00),
Taxes: decimal.NewFromFloat(4.00),
},
{
BuyValue: decimal.NewFromFloat(50.00),
SellValue: decimal.NewFromFloat(55.00),
Fees: decimal.NewFromFloat(0.50),
Taxes: decimal.NewFromFloat(1.50),
},
},
wantEarned: decimal.NewFromFloat(425.00),
wantSpent: decimal.NewFromFloat(350.00),
wantFees: decimal.NewFromFloat(4.50),
wantTaxes: decimal.NewFromFloat(7.50),
},
{
name: "empty writer returns zero totals",
items: []internal.ReportItem{},
wantEarned: decimal.Zero,
wantSpent: decimal.Zero,
wantFees: decimal.Zero,
wantTaxes: decimal.Zero,
},
{
name: "handles zero values",
items: []internal.ReportItem{
{
BuyValue: decimal.Zero,
SellValue: decimal.Zero,
Fees: decimal.Zero,
Taxes: decimal.Zero,
},
},
wantEarned: decimal.Zero,
wantSpent: decimal.Zero,
wantFees: decimal.Zero,
wantTaxes: decimal.Zero,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aw := &internal.AggregatorWriter{}
ctx := t.Context()
for _, item := range tt.items {
if err := aw.Write(ctx, item); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
assertDecimalEqual(t, "TotalEarned", tt.wantEarned, aw.TotalEarned())
assertDecimalEqual(t, "TotalSpent", tt.wantSpent, aw.TotalSpent())
assertDecimalEqual(t, "TotalFees", tt.wantFees, aw.TotalFees())
assertDecimalEqual(t, "TotalTaxes", tt.wantTaxes, aw.TotalTaxes())
})
}
}
func TestAggregatorWriter_Rounding(t *testing.T) {
tests := []struct {
name string
items []internal.ReportItem
wantEarned decimal.Decimal
wantSpent decimal.Decimal
wantFees decimal.Decimal
wantTaxes decimal.Decimal
}{
{
name: "rounds to 2 decimal places",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(100.123456),
SellValue: decimal.NewFromFloat(150.987654),
Fees: decimal.NewFromFloat(2.555555),
Taxes: decimal.NewFromFloat(5.444444),
},
},
wantEarned: decimal.NewFromFloat(150.99),
wantSpent: decimal.NewFromFloat(100.12),
wantFees: decimal.NewFromFloat(2.56),
wantTaxes: decimal.NewFromFloat(5.44),
},
{
name: "rounding accumulates correctly across multiple writes",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(10.111),
SellValue: decimal.NewFromFloat(15.999),
Fees: decimal.NewFromFloat(0.555),
Taxes: decimal.NewFromFloat(1.445),
},
{
BuyValue: decimal.NewFromFloat(20.222),
SellValue: decimal.NewFromFloat(25.001),
Fees: decimal.NewFromFloat(0.444),
Taxes: decimal.NewFromFloat(0.555),
},
},
// Each write rounds individually, then accumulates
// First: 10.11 + 20.22 = 30.33
// Second: 16.00 + 25.00 = 41.00
// Fees: 0.56 + 0.44 = 1.00
// Taxes: 1.45 + 0.56 = 2.01
wantSpent: decimal.NewFromFloat(30.33),
wantEarned: decimal.NewFromFloat(41.00),
wantFees: decimal.NewFromFloat(1.00),
wantTaxes: decimal.NewFromFloat(2.01),
},
{
name: "handles small fractions",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(0.001),
SellValue: decimal.NewFromFloat(0.009),
Fees: decimal.NewFromFloat(0.0055),
Taxes: decimal.NewFromFloat(0.0045),
},
},
wantSpent: decimal.NewFromFloat(0.00),
wantEarned: decimal.NewFromFloat(0.01),
wantFees: decimal.NewFromFloat(0.01),
wantTaxes: decimal.NewFromFloat(0.00),
},
{
name: "handles large numbers with precision",
items: []internal.ReportItem{
{
BuyValue: decimal.NewFromFloat(999999.996),
SellValue: decimal.NewFromFloat(1000000.004),
Fees: decimal.NewFromFloat(12345.678),
Taxes: decimal.NewFromFloat(54321.123),
},
},
wantSpent: decimal.NewFromFloat(1000000.00),
wantEarned: decimal.NewFromFloat(1000000.00),
wantFees: decimal.NewFromFloat(12345.68),
wantTaxes: decimal.NewFromFloat(54321.12),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aw := &internal.AggregatorWriter{}
ctx := t.Context()
for _, item := range tt.items {
if err := aw.Write(ctx, item); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
assertDecimalEqual(t, "TotalEarned", tt.wantEarned, aw.TotalEarned())
assertDecimalEqual(t, "TotalSpent", tt.wantSpent, aw.TotalSpent())
assertDecimalEqual(t, "TotalFees", tt.wantFees, aw.TotalFees())
assertDecimalEqual(t, "TotalTaxes", tt.wantTaxes, aw.TotalTaxes())
})
}
}
func TestAggregatorWriter_Items(t *testing.T) {
aw := &internal.AggregatorWriter{}
ctx := t.Context()
for range 5 {
item := internal.ReportItem{Symbol: "TEST"}
if err := aw.Write(ctx, item); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
count := 0
for range aw.Iter() {
count++
}
if count != 5 {
t.Errorf("expected for loop to stop at 5 items, got %d", count)
}
count = 0
for range aw.Iter() {
count++
if count == 3 {
break
}
}
if count != 3 {
t.Errorf("expected for loop to stop at 3 items, got %d", count)
}
}
func TestAggregatorWriter_ThreadSafety(t *testing.T) {
aw := &internal.AggregatorWriter{}
ctx := t.Context()
numGoroutines := 100
writesPerGoroutine := 100
var wg sync.WaitGroup
for range numGoroutines {
wg.Go(func() {
for range writesPerGoroutine {
item := internal.ReportItem{
BuyValue: decimal.NewFromFloat(1.00),
SellValue: decimal.NewFromFloat(2.00),
Fees: decimal.NewFromFloat(0.10),
Taxes: decimal.NewFromFloat(0.20),
}
if err := aw.Write(ctx, item); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
wg.Wait()
// Verify totals are correct
wantWrites := numGoroutines * writesPerGoroutine
wantSpent := decimal.NewFromFloat(float64(wantWrites) * 1.00)
wantEarned := decimal.NewFromFloat(float64(wantWrites) * 2.00)
wantFees := decimal.NewFromFloat(float64(wantWrites) * 0.10)
wantTaxes := decimal.NewFromFloat(float64(wantWrites) * 0.20)
assertDecimalEqual(t, "TotalSpent", wantSpent, aw.TotalSpent())
assertDecimalEqual(t, "TotalEarned", wantEarned, aw.TotalEarned())
assertDecimalEqual(t, "TotalFees", wantFees, aw.TotalFees())
assertDecimalEqual(t, "TotalTaxes", wantTaxes, aw.TotalTaxes())
}
// Helper function to assert decimal equality
func assertDecimalEqual(t *testing.T, name string, expected, actual decimal.Decimal) {
t.Helper()
if !expected.Equal(actual) {
t.Errorf("want %s to be %s but got %s", name, expected.String(), actual.String())
}
}
+14
View File
@@ -0,0 +1,14 @@
package internal
import "context"
// EphemeralStore loads an empty state and discards everything on save.
type EphemeralStore struct{}
func (EphemeralStore) Load(context.Context) (map[string]*FillerQueue, error) {
return make(map[string]*FillerQueue), nil
}
func (EphemeralStore) Save(context.Context, map[string]*FillerQueue) error {
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package internal_test
import (
"testing"
"github.com/nmoniz/any2anexoj/internal"
)
// Verify that EphemeralStore.Load returns a non-nil empty map and no error.
func TestEphemeralStore_Load(t *testing.T) {
store := internal.EphemeralStore{}
queues, err := store.Load(t.Context())
if err != nil {
t.Fatalf("Load returned unexpected error: %v", err)
}
if queues == nil {
t.Fatalf("Load returned a nil map; expected an empty map")
}
if len(queues) != 0 {
t.Fatalf("Load returned %d entries; expected 0", len(queues))
}
}
// Verify that EphemeralStore.Save accepts a queue map without error and
// discards its contents.
func TestEphemeralStore_Save(t *testing.T) {
store := internal.EphemeralStore{}
var q internal.FillerQueue
err := store.Save(t.Context(), map[string]*internal.FillerQueue{
"TEST": &q,
})
if err != nil {
t.Fatalf("Save returned unexpected error: %v", err)
}
// Save is a no-op, so a subsequent Load must still be empty.
queues, err := store.Load(t.Context())
if err != nil {
t.Fatalf("Load after Save returned unexpected error: %v", err)
}
if len(queues) != 0 {
t.Fatalf("Load after Save returned %d entries; expected 0", len(queues))
}
}
+5
View File
@@ -0,0 +1,5 @@
package internal
import "fmt"
var ErrInsufficientBoughtVolume = fmt.Errorf("insufficient bought volume")
+138
View File
@@ -0,0 +1,138 @@
package internal
import (
"container/list"
"github.com/shopspring/decimal"
)
type Filler struct {
Record
filled decimal.Decimal
quantity decimal.Decimal
price decimal.Decimal
}
func NewFiller(r Record) *Filler {
return &Filler{
Record: r,
quantity: r.Quantity(),
price: r.Price(),
}
}
// NewFillerFromState constructs a Filler from a previously persisted state.
// It bypasses the Record-derived defaults so callers can restore a lot that
// may have been split-adjusted or partially filled since the Record was first
// created.
func NewFillerFromState(record Record, quantity, price, filled decimal.Decimal) *Filler {
return &Filler{
Record: record,
filled: filled,
quantity: quantity,
price: price,
}
}
func (f *Filler) Quantity() decimal.Decimal { return f.quantity }
func (f *Filler) Price() decimal.Decimal { return f.price }
// Filled returns how much of the Filler's quantity has already been consumed.
func (f *Filler) Filled() decimal.Decimal { return f.filled }
// Fill accrues some quantity. Returns how mutch was accrued in the 1st return value and whether
// it was filled or not on the 2nd return value.
func (f *Filler) Fill(quantity decimal.Decimal) (decimal.Decimal, bool) {
unfilled := f.quantity.Sub(f.filled)
delta := decimal.Min(unfilled, quantity)
f.filled = f.filled.Add(delta)
return delta, f.IsFilled()
}
// IsFilled returns true if the fill is equal to the record quantity.
func (f *Filler) IsFilled() bool {
return f.filled.Equal(f.quantity)
}
// ApplySplit adjusts the lot for a stock split by the given ratio (newQty/oldQty).
// The total cost basis is preserved: quantity scales up, price scales down proportionally.
func (f *Filler) ApplySplit(ratio decimal.Decimal) {
f.quantity = f.quantity.Mul(ratio)
f.filled = f.filled.Mul(ratio)
f.price = f.price.Div(ratio)
}
type FillerQueue struct {
l *list.List
}
// Push inserts the Filler at the back of the queue.
func (fq *FillerQueue) Push(f *Filler) {
if f == nil {
return
}
if fq == nil {
// This would cause a panic anyway so, we panic with a more meaningful message
panic("Push to nil FillerQueue")
}
if fq.l == nil {
fq.l = list.New()
}
fq.l.PushBack(f)
}
// Pop removes and returns the first Filler of the queue in the 1st return value if there is one. If
// the queue is already empty returns false on the 2nd return value, otherwise returns true.
func (fq *FillerQueue) Pop() (*Filler, bool) {
el := fq.frontElement()
if el == nil {
return nil, false
}
val := fq.l.Remove(el)
return val.(*Filler), true
}
// Peek returns the front Filler of the queue in the 1st return value. If the list is empty returns
// false on the 2nd return value, true otherwise.
func (fq *FillerQueue) Peek() (*Filler, bool) {
el := fq.frontElement()
if el == nil {
return nil, false
}
return el.Value.(*Filler), true
}
func (fq *FillerQueue) frontElement() *list.Element {
if fq == nil || fq.l == nil {
return nil
}
return fq.l.Front()
}
// AdjustForSplit applies a stock split ratio to all lots in the queue.
func (fq *FillerQueue) AdjustForSplit(ratio decimal.Decimal) {
if fq == nil || fq.l == nil {
return
}
for e := fq.l.Front(); e != nil; e = e.Next() {
e.Value.(*Filler).ApplySplit(ratio)
}
}
// Len returns how many elements are currently on the queue
func (fq *FillerQueue) Len() int {
if fq == nil || fq.l == nil {
return 0
}
return fq.l.Len()
}
+317
View File
@@ -0,0 +1,317 @@
package internal
import (
"testing"
"github.com/shopspring/decimal"
)
func TestFillerQueue(t *testing.T) {
var recCount int
newRecord := func() Record {
recCount++
return testRecord{
id: recCount,
}
}
var rq FillerQueue
if rq.Len() != 0 {
t.Fatalf("zero value should have zero length")
}
_, ok := rq.Pop()
if ok {
t.Fatalf("Pop() should return (_,false) on a zero value")
}
_, ok = rq.Peek()
if ok {
t.Fatalf("Peek() should return (_,false) on a zero value")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("pushing nil should be a no-op")
}
rq.Push(NewFiller(newRecord()))
if rq.Len() != 1 {
t.Fatalf("pushing 1st record should result in length of 1")
}
rq.Push(NewFiller(newRecord()))
if rq.Len() != 2 {
t.Fatalf("pushing 2nd record should result in length of 2")
}
peekFiller, ok := rq.Peek()
if !ok {
t.Fatalf("Peek() should return (_,true) when the list is not empty")
}
if rec, ok := peekFiller.Record.(testRecord); ok {
if rec.id != 1 {
t.Fatalf("Peek() should return the 1st record pushed but returned %d", rec.id)
}
} else {
t.Fatalf("Peek() should return the original record type")
}
if rq.Len() != 2 {
t.Fatalf("Peek() should not affect the list length")
}
popFiller, ok := rq.Pop()
if !ok {
t.Fatalf("Pop() should return (_,true) when the list is not empty")
}
if rec, ok := popFiller.Record.(testRecord); ok {
if rec.id != 1 {
t.Fatalf("Pop() should return the first record pushed but returned %d", rec.id)
}
} else {
t.Fatalf("Pop() should return the original record")
}
if rq.Len() != 1 {
t.Fatalf("Pop() should remove an element from the list")
}
}
func TestFillerQueueNilReceiver(t *testing.T) {
var rq *FillerQueue
if rq.Len() > 0 {
t.Fatalf("nil receiver should have zero length")
}
_, ok := rq.Peek()
if ok {
t.Fatalf("Peek() on a nil receiver should return (_,false)")
}
_, ok = rq.Pop()
if ok {
t.Fatalf("Pop() on a nil receiver should return (_,false)")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("Push(nil) on a nil receiver should be a no-op")
}
defer func() {
r := recover()
if r == nil {
t.Fatalf("expected a panic but got nothing")
}
expMsg := "Push to nil FillerQueue"
if msg, ok := r.(string); !ok || msg != expMsg {
t.Fatalf(`want panic message %q but got "%v"`, expMsg, r)
}
}()
rq.Push(NewFiller(&testRecord{}))
}
type testRecord struct {
Record
id int
quantity decimal.Decimal
price decimal.Decimal
}
func (tr testRecord) Quantity() decimal.Decimal { return tr.quantity }
func (tr testRecord) Price() decimal.Decimal { return tr.price }
func TestFiller_Fill(t *testing.T) {
tests := []struct {
name string
r Record
quantity decimal.Decimal
want decimal.Decimal
wantBool bool
}{
{
name: "fills 0 of zero quantity",
r: &testRecord{quantity: decimal.NewFromFloat(0.0)},
quantity: decimal.Decimal{},
want: decimal.Decimal{},
wantBool: true,
},
{
name: "fills 0 of positive quantity",
r: &testRecord{quantity: decimal.NewFromFloat(100.0)},
quantity: decimal.Decimal{},
want: decimal.Decimal{},
wantBool: false,
},
{
name: "fills 10 out of 100 and no previous fills",
r: &testRecord{quantity: decimal.NewFromFloat(100.0)},
quantity: decimal.NewFromFloat(10),
want: decimal.NewFromFloat(10),
wantBool: false,
},
{
name: "fills 10 out of 10 and no previous fills",
r: &testRecord{quantity: decimal.NewFromFloat(10.0)},
quantity: decimal.NewFromFloat(10),
want: decimal.NewFromFloat(10),
wantBool: true,
},
{
name: "filling 100 fills 10 out of 10 and no previous fills",
r: &testRecord{quantity: decimal.NewFromFloat(10.0)},
quantity: decimal.NewFromFloat(100),
want: decimal.NewFromFloat(10),
wantBool: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := NewFiller(tt.r)
got, gotBool := f.Fill(tt.quantity)
if !tt.want.Equal(got) {
t.Errorf("want 1st return value to be %v but got %v", tt.want, got)
}
if tt.wantBool != gotBool {
t.Errorf("want 2nd return value to be %v but got %v", tt.wantBool, gotBool)
}
})
}
}
func TestFiller_ApplySplit(t *testing.T) {
tests := []struct {
name string
qty float64
price float64
prefilled float64
ratio float64
wantQty float64
wantPrice float64
wantFilled float64
wantCostBasis float64
}{
{
name: "5:1 split on unfilled lot preserves cost basis",
qty: 10, price: 100, prefilled: 0, ratio: 5,
wantQty: 50, wantPrice: 20, wantFilled: 0, wantCostBasis: 1000,
},
{
name: "5:1 split on partially filled lot",
qty: 10, price: 100, prefilled: 4, ratio: 5,
wantQty: 50, wantPrice: 20, wantFilled: 20, wantCostBasis: 1000,
},
{
name: "1:2 reverse split on unfilled lot preserves cost basis",
qty: 10, price: 100, prefilled: 0, ratio: 0.5,
wantQty: 5, wantPrice: 200, wantFilled: 0, wantCostBasis: 1000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := NewFiller(&testRecord{
quantity: decimal.NewFromFloat(tt.qty),
price: decimal.NewFromFloat(tt.price),
})
if tt.prefilled > 0 {
f.Fill(decimal.NewFromFloat(tt.prefilled))
}
f.ApplySplit(decimal.NewFromFloat(tt.ratio))
if !f.Quantity().Equal(decimal.NewFromFloat(tt.wantQty)) {
t.Errorf("want quantity %v but got %v", tt.wantQty, f.Quantity())
}
if !f.Price().Equal(decimal.NewFromFloat(tt.wantPrice)) {
t.Errorf("want price %v but got %v", tt.wantPrice, f.Price())
}
if !f.filled.Equal(decimal.NewFromFloat(tt.wantFilled)) {
t.Errorf("want filled %v but got %v", tt.wantFilled, f.filled)
}
costBasis := f.Quantity().Mul(f.Price())
if !costBasis.Equal(decimal.NewFromFloat(tt.wantCostBasis)) {
t.Errorf("want cost basis %v but got %v", tt.wantCostBasis, costBasis)
}
})
}
}
func TestFillerQueue_AdjustForSplit(t *testing.T) {
var fq FillerQueue
fq.Push(NewFiller(&testRecord{quantity: decimal.NewFromFloat(10), price: decimal.NewFromFloat(100)}))
fq.Push(NewFiller(&testRecord{quantity: decimal.NewFromFloat(5), price: decimal.NewFromFloat(200)}))
fq.AdjustForSplit(decimal.NewFromFloat(5))
lot1, _ := fq.Pop()
if !lot1.Quantity().Equal(decimal.NewFromFloat(50)) {
t.Errorf("lot1: want quantity 50 but got %v", lot1.Quantity())
}
if !lot1.Price().Equal(decimal.NewFromFloat(20)) {
t.Errorf("lot1: want price 20 but got %v", lot1.Price())
}
lot2, _ := fq.Pop()
if !lot2.Quantity().Equal(decimal.NewFromFloat(25)) {
t.Errorf("lot2: want quantity 25 but got %v", lot2.Quantity())
}
if !lot2.Price().Equal(decimal.NewFromFloat(40)) {
t.Errorf("lot2: want price 40 but got %v", lot2.Price())
}
}
func TestFillerQueue_AdjustForSplit_NilReceiver(t *testing.T) {
var fq *FillerQueue
fq.AdjustForSplit(decimal.NewFromFloat(5)) // must not panic
}
func TestNewFillerFromState(t *testing.T) {
// The underlying Record reports very different values; NewFillerFromState
// must ignore them and use the caller-supplied overrides instead.
rec := &testRecord{
quantity: decimal.NewFromFloat(999),
price: decimal.NewFromFloat(999),
}
qty := decimal.NewFromFloat(50)
price := decimal.NewFromFloat(20)
filled := qty // fully filled on load
f := NewFillerFromState(rec, qty, price, filled)
if !f.Quantity().Equal(qty) {
t.Errorf("want quantity %v but got %v", qty, f.Quantity())
}
if !f.Price().Equal(price) {
t.Errorf("want price %v but got %v", price, f.Price())
}
if !f.IsFilled() {
t.Errorf("want IsFilled() to be true when filled == quantity")
}
// Partially filled: filled < quantity => IsFilled must be false.
partial := NewFillerFromState(rec, qty, price, decimal.NewFromFloat(10))
if partial.IsFilled() {
t.Errorf("want IsFilled() to be false when filled < quantity")
}
if !partial.Quantity().Equal(qty) {
t.Errorf("want quantity %v but got %v", qty, partial.Quantity())
}
if !partial.Price().Equal(price) {
t.Errorf("want price %v but got %v", price, partial.Price())
}
// Filled from Fill() on a restored lot must work correctly against the
// restored (not Record-derived) quantity/price.
_, done := partial.Fill(decimal.NewFromFloat(40))
if !done {
t.Errorf("after filling the remaining 40, IsFilled() should be true")
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
package internal package internal
//go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter //go:generate go tool mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer
+179
View File
@@ -0,0 +1,179 @@
package internal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// JSONStore 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 JSONStore struct {
filename string
platform string
serializer RecordSerializer
}
// NewJSONStore constructs a JSONStore that reads from and writes to the given
// filename. The JSONStore 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 NewJSONStore(filename string, platform string, serializer RecordSerializer) (*JSONStore, error) {
if filename == "" {
return nil, fmt.Errorf("filename cannot be empty")
}
if serializer == nil {
return nil, fmt.Errorf("serializer cannot be nil")
}
return &JSONStore{
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 *JSONStore) 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 *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) error {
queues := make(map[string][]persistedFiller)
latest := time.Time{}
earliest := time.Time{}
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(),
})
if f.Timestamp().After(latest) {
latest = f.Timestamp()
}
if f.Timestamp().Before(earliest) || earliest.IsZero() {
earliest = f.Timestamp()
}
}
queues[symbol] = persisted
}
state := State{
Version: StateVersion,
Platform: fs.platform,
Queues: queues,
Latest: latest,
Earliest: earliest,
}
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetIndent("", " ")
if err := enc.Encode(state); err != nil {
return fmt.Errorf("encoding state: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(fs.filename), filepath.Base(fs.filename)+".*.tmp")
if err != nil {
return fmt.Errorf("creating temp state file: %w", err)
}
tmpFilename := tmp.Name()
defer os.Remove(tmpFilename)
defer tmp.Close()
if _, err := io.Copy(tmp, buf); err != nil {
return fmt.Errorf("writing temp state file: %w", err)
}
if err := tmp.Sync(); err != nil {
return fmt.Errorf("syncing temp state file: %w", err)
}
// Closing the temp file before renaming/moving for cross-platform compatibility
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing temp state file: %w", err)
}
ext := filepath.Ext(fs.filename)
name, _ := strings.CutSuffix(fs.filename, ext)
backupFilename := name + "." + time.Now().Format("2006-01-02_150405") + ext
if err := os.Rename(fs.filename, backupFilename); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("rename old state file: %w", err)
}
}
if err := os.Rename(tmpFilename, fs.filename); err != nil {
return fmt.Errorf("atomic rename of state file: %w", err)
}
return nil
}
+496
View File
@@ -0,0 +1,496 @@
package internal_test
import (
"context"
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/mocks"
"github.com/shopspring/decimal"
"go.uber.org/mock/gomock"
)
func TestJSONStore_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)
}
body := string(data)
if strings.Contains(body, `"reader_data"`) {
t.Errorf("saved state file still contains legacy key \"reader_data\"; want only \"record_data\"")
}
if !strings.Contains(body, `"record_data"`) {
t.Errorf("saved state file does not contain expected key \"record_data\"")
}
// Regression: the "record_data" value must be inline JSON, not a base64
// blob. Version-1 state files encoded the per-record JSON as a base64
// string (which always starts with "ey" — base64 of '{' followed by '"').
// After Chunk 1 of the refactor the value is json.RawMessage, which the
// JSON encoder emits inline. roundTripSerializer emits a JSON string per
// symbol, so the file should contain `"record_data": "AAA"` etc.
if strings.Contains(body, `"record_data": "ey`) {
t.Errorf("saved state file still has base64-encoded record_data (version-1 format); want inline JSON")
}
wantInlineAAA := `"record_data": "AAA"`
if !strings.Contains(body, wantInlineAAA) {
t.Errorf("saved state file does not contain inline JSON %q under record_data; got:\n%s", wantInlineAAA, body)
}
// Sanity: the file must be valid top-level JSON (it always was, but
// confirming the inline form is parseable guards against accidentally
// double-encoding it again in the future).
var parsed struct {
Latest time.Time `json:"latest"`
Earliest time.Time `json:"earliest"`
Queues map[string][]struct {
RecordData json.RawMessage `json:"record_data"`
} `json:"queues"`
}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("saved state file is not valid JSON: %v", err)
}
// The latest/earliest bounds must be persisted alongside the queues.
// Every record in this test returns testRecordTime, so both bounds must
// equal it exactly.
if !parsed.Latest.Equal(testRecordTime) {
t.Errorf("state latest = %v; want %v", parsed.Latest, testRecordTime)
}
if !parsed.Earliest.Equal(testRecordTime) {
t.Errorf("state earliest = %v; want %v", parsed.Earliest, testRecordTime)
}
for symbol, lots := range parsed.Queues {
for i, lot := range lots {
if len(lot.RecordData) == 0 {
t.Errorf("symbol %q lot %d: record_data is empty", symbol, i)
}
if !json.Valid(lot.RecordData) {
t.Errorf("symbol %q lot %d: record_data is not valid JSON: %s", symbol, i, lot.RecordData)
}
}
}
}
func TestJSONStore_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 TestJSONStore_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 TestJSONStore_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 TestJSONStore_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 TestJSONStore_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 TestJSONStore_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 TestJSONStore_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 TestNewJSONStore_ValidatesArguments(t *testing.T) {
ctrl := gomock.NewController(t)
ser := mocks.NewMockRecordSerializer(ctrl)
if _, err := internal.NewJSONStore("", "fake", ser); err == nil {
t.Errorf("NewJSONStore with empty filename should fail")
}
if _, err := internal.NewJSONStore("/tmp/x", "fake", nil); err == nil {
t.Errorf("NewJSONStore with nil serializer should fail")
}
}
// testRecordTime is the deterministic timestamp returned by every record
// built in this file. Using a fixed value keeps the latest/earliest bounds
// written by JSONStore.Save predictable and assertable.
var testRecordTime = time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
// newRecord builds a MockRecord whose Symbol() returns the given symbol and
// whose Timestamp() returns testRecordTime. The JSONStore only reads Symbol()
// and Timestamp() off the 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()
r.EXPECT().Timestamp().Return(testRecordTime).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 JSONStore 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.JSONStore, string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "state.json")
store, err := internal.NewJSONStore(path, platform, ser)
if err != nil {
t.Fatalf("NewJSONStore returned unexpected error: %v", err)
}
return store, path
}
// roundTripSerializer returns a serializer mock whose MarshalRecord encodes
// the Symbol as a JSON string (e.g. "AAA") and whose UnmarshalRecord decodes
// those JSON 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) (json.RawMessage, error) {
return json.Marshal(r.Symbol())
}).
AnyTimes()
ser.EXPECT().
UnmarshalRecord(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, b json.RawMessage) (internal.Record, error) {
var symbol string
if err := json.Unmarshal(b, &symbol); err != nil {
return nil, err
}
return newRecord(ctrl, symbol), 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
}
+36
View File
@@ -0,0 +1,36 @@
package internal
type Kind uint
const (
KindUnknown Kind = iota
KindBuy
KindSell
KindSplit
sentinelKind
)
// String returns a unique string value for Kind k
func (k Kind) String() string {
switch k {
case KindBuy:
return "buy"
case KindSell:
return "sell"
case KindSplit:
return "split"
default:
return "unknown"
}
}
// Valid returns true if k is an accepted value for the Kind type
func (k Kind) Valid() bool {
return k > 0 && k < sentinelKind
}
// Is returns true when k and o are valid and equal.
func (k Kind) Is(o any) bool {
other, ok := o.(Kind)
return ok && k.Valid() && k == other
}
+95
View File
@@ -0,0 +1,95 @@
package internal
import (
"fmt"
"testing"
)
func TestSide_String(t *testing.T) {
const unknown = "unknown"
seen := make(map[string]Kind, sentinelKind)
for k := Kind(1); k < sentinelKind; k++ {
t.Run(fmt.Sprintf("Kind %d", k), func(t *testing.T) {
str := k.String()
if other, ok := seen[str]; ok {
t.Errorf("want Kind(%d).String to be unique but was a duplicate of Kind(%d)", k, other)
} else {
seen[str] = k
}
if len(str) == 0 {
t.Errorf("want Kind(%d).String to be non-empty", k)
}
if str == unknown {
t.Errorf("want Kind(%d).String to be a known value", k)
}
})
}
if KindUnknown.String() != unknown {
t.Errorf("want Kind(0) to be unknown")
}
if Kind(sentinelKind).String() != unknown {
t.Errorf("want Kind(%d) to be unknown", sentinelKind)
}
}
func TestSide_Valid(t *testing.T) {
for k := Kind(1); k < sentinelKind; k++ {
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)
}
})
}
+418 -74
View File
@@ -1,20 +1,22 @@
// Code generated by MockGen. DO NOT EDIT. // Code generated by MockGen. DO NOT EDIT.
// Source: git.naterciomoniz.net/applications/broker2anexoj/internal (interfaces: RecordReader,Record,ReportWriter) // Source: github.com/nmoniz/any2anexoj/internal (interfaces: RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer)
// //
// Generated by this command: // Generated by this command:
// //
// mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter // mockgen -destination=mocks/mocks_gen.go -package=mocks -typed . RecordReader,Record,ReportWriter,RecordEncoder,RecordDecoder,RecordSerializer
// //
// Package mocks is a generated GoMock package. // Package mocks is a generated GoMock package.
package mocks package mocks
import ( import (
big "math/big" context "context"
json "encoding/json"
reflect "reflect" reflect "reflect"
time "time" time "time"
internal "git.naterciomoniz.net/applications/broker2anexoj/internal" internal "github.com/nmoniz/any2anexoj/internal"
decimal "github.com/shopspring/decimal"
gomock "go.uber.org/mock/gomock" gomock "go.uber.org/mock/gomock"
) )
@@ -43,18 +45,18 @@ func (m *MockRecordReader) EXPECT() *MockRecordReaderMockRecorder {
} }
// ReadRecord mocks base method. // ReadRecord mocks base method.
func (m *MockRecordReader) ReadRecord() (internal.Record, error) { func (m *MockRecordReader) ReadRecord(arg0 context.Context) (internal.Record, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ReadRecord") ret := m.ctrl.Call(m, "ReadRecord", arg0)
ret0, _ := ret[0].(internal.Record) ret0, _ := ret[0].(internal.Record)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
// ReadRecord indicates an expected call of ReadRecord. // ReadRecord indicates an expected call of ReadRecord.
func (mr *MockRecordReaderMockRecorder) ReadRecord() *MockRecordReaderReadRecordCall { func (mr *MockRecordReaderMockRecorder) ReadRecord(arg0 any) *MockRecordReaderReadRecordCall {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadRecord", reflect.TypeOf((*MockRecordReader)(nil).ReadRecord)) call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadRecord", reflect.TypeOf((*MockRecordReader)(nil).ReadRecord), arg0)
return &MockRecordReaderReadRecordCall{Call: call} return &MockRecordReaderReadRecordCall{Call: call}
} }
@@ -70,13 +72,13 @@ func (c *MockRecordReaderReadRecordCall) Return(arg0 internal.Record, arg1 error
} }
// Do rewrite *gomock.Call.Do // Do rewrite *gomock.Call.Do
func (c *MockRecordReaderReadRecordCall) Do(f func() (internal.Record, error)) *MockRecordReaderReadRecordCall { func (c *MockRecordReaderReadRecordCall) Do(f func(context.Context) (internal.Record, error)) *MockRecordReaderReadRecordCall {
c.Call = c.Call.Do(f) c.Call = c.Call.Do(f)
return c return c
} }
// DoAndReturn rewrite *gomock.Call.DoAndReturn // DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordReaderReadRecordCall) DoAndReturn(f func() (internal.Record, error)) *MockRecordReaderReadRecordCall { func (c *MockRecordReaderReadRecordCall) DoAndReturn(f func(context.Context) (internal.Record, error)) *MockRecordReaderReadRecordCall {
c.Call = c.Call.DoAndReturn(f) c.Call = c.Call.DoAndReturn(f)
return c return c
} }
@@ -105,11 +107,87 @@ func (m *MockRecord) EXPECT() *MockRecordMockRecorder {
return m.recorder return m.recorder
} }
// AssetCountry mocks base method.
func (m *MockRecord) AssetCountry() int64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AssetCountry")
ret0, _ := ret[0].(int64)
return ret0
}
// AssetCountry indicates an expected call of AssetCountry.
func (mr *MockRecordMockRecorder) AssetCountry() *MockRecordAssetCountryCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AssetCountry", reflect.TypeOf((*MockRecord)(nil).AssetCountry))
return &MockRecordAssetCountryCall{Call: call}
}
// MockRecordAssetCountryCall wrap *gomock.Call
type MockRecordAssetCountryCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordAssetCountryCall) Return(arg0 int64) *MockRecordAssetCountryCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordAssetCountryCall) Do(f func() int64) *MockRecordAssetCountryCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordAssetCountryCall) DoAndReturn(f func() int64) *MockRecordAssetCountryCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// BrokerCountry mocks base method.
func (m *MockRecord) BrokerCountry() int64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "BrokerCountry")
ret0, _ := ret[0].(int64)
return ret0
}
// BrokerCountry indicates an expected call of BrokerCountry.
func (mr *MockRecordMockRecorder) BrokerCountry() *MockRecordBrokerCountryCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BrokerCountry", reflect.TypeOf((*MockRecord)(nil).BrokerCountry))
return &MockRecordBrokerCountryCall{Call: call}
}
// MockRecordBrokerCountryCall wrap *gomock.Call
type MockRecordBrokerCountryCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordBrokerCountryCall) Return(arg0 int64) *MockRecordBrokerCountryCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordBrokerCountryCall) Do(f func() int64) *MockRecordBrokerCountryCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordBrokerCountryCall) DoAndReturn(f func() int64) *MockRecordBrokerCountryCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Fees mocks base method. // Fees mocks base method.
func (m *MockRecord) Fees() *big.Float { func (m *MockRecord) Fees() decimal.Decimal {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Fees") ret := m.ctrl.Call(m, "Fees")
ret0, _ := ret[0].(*big.Float) ret0, _ := ret[0].(decimal.Decimal)
return ret0 return ret0
} }
@@ -126,28 +204,104 @@ type MockRecordFeesCall struct {
} }
// Return rewrite *gomock.Call.Return // Return rewrite *gomock.Call.Return
func (c *MockRecordFeesCall) Return(arg0 *big.Float) *MockRecordFeesCall { func (c *MockRecordFeesCall) Return(arg0 decimal.Decimal) *MockRecordFeesCall {
c.Call = c.Call.Return(arg0) c.Call = c.Call.Return(arg0)
return c return c
} }
// Do rewrite *gomock.Call.Do // Do rewrite *gomock.Call.Do
func (c *MockRecordFeesCall) Do(f func() *big.Float) *MockRecordFeesCall { func (c *MockRecordFeesCall) Do(f func() decimal.Decimal) *MockRecordFeesCall {
c.Call = c.Call.Do(f) c.Call = c.Call.Do(f)
return c return c
} }
// DoAndReturn rewrite *gomock.Call.DoAndReturn // DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordFeesCall) DoAndReturn(f func() *big.Float) *MockRecordFeesCall { func (c *MockRecordFeesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordFeesCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Kind mocks base method.
func (m *MockRecord) Kind() internal.Kind {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Kind")
ret0, _ := ret[0].(internal.Kind)
return ret0
}
// Kind indicates an expected call of Kind.
func (mr *MockRecordMockRecorder) Kind() *MockRecordKindCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kind", reflect.TypeOf((*MockRecord)(nil).Kind))
return &MockRecordKindCall{Call: call}
}
// MockRecordKindCall wrap *gomock.Call
type MockRecordKindCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordKindCall) Return(arg0 internal.Kind) *MockRecordKindCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordKindCall) Do(f func() internal.Kind) *MockRecordKindCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordKindCall) DoAndReturn(f func() internal.Kind) *MockRecordKindCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Nature mocks base method.
func (m *MockRecord) Nature() internal.Nature {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Nature")
ret0, _ := ret[0].(internal.Nature)
return ret0
}
// Nature indicates an expected call of Nature.
func (mr *MockRecordMockRecorder) Nature() *MockRecordNatureCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Nature", reflect.TypeOf((*MockRecord)(nil).Nature))
return &MockRecordNatureCall{Call: call}
}
// MockRecordNatureCall wrap *gomock.Call
type MockRecordNatureCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordNatureCall) Return(arg0 internal.Nature) *MockRecordNatureCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordNatureCall) Do(f func() internal.Nature) *MockRecordNatureCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordNatureCall) DoAndReturn(f func() internal.Nature) *MockRecordNatureCall {
c.Call = c.Call.DoAndReturn(f) c.Call = c.Call.DoAndReturn(f)
return c return c
} }
// Price mocks base method. // Price mocks base method.
func (m *MockRecord) Price() *big.Float { func (m *MockRecord) Price() decimal.Decimal {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Price") ret := m.ctrl.Call(m, "Price")
ret0, _ := ret[0].(*big.Float) ret0, _ := ret[0].(decimal.Decimal)
return ret0 return ret0
} }
@@ -164,28 +318,28 @@ type MockRecordPriceCall struct {
} }
// Return rewrite *gomock.Call.Return // Return rewrite *gomock.Call.Return
func (c *MockRecordPriceCall) Return(arg0 *big.Float) *MockRecordPriceCall { func (c *MockRecordPriceCall) Return(arg0 decimal.Decimal) *MockRecordPriceCall {
c.Call = c.Call.Return(arg0) c.Call = c.Call.Return(arg0)
return c return c
} }
// Do rewrite *gomock.Call.Do // Do rewrite *gomock.Call.Do
func (c *MockRecordPriceCall) Do(f func() *big.Float) *MockRecordPriceCall { func (c *MockRecordPriceCall) Do(f func() decimal.Decimal) *MockRecordPriceCall {
c.Call = c.Call.Do(f) c.Call = c.Call.Do(f)
return c return c
} }
// DoAndReturn rewrite *gomock.Call.DoAndReturn // DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordPriceCall) DoAndReturn(f func() *big.Float) *MockRecordPriceCall { func (c *MockRecordPriceCall) DoAndReturn(f func() decimal.Decimal) *MockRecordPriceCall {
c.Call = c.Call.DoAndReturn(f) c.Call = c.Call.DoAndReturn(f)
return c return c
} }
// Quantity mocks base method. // Quantity mocks base method.
func (m *MockRecord) Quantity() *big.Float { func (m *MockRecord) Quantity() decimal.Decimal {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Quantity") ret := m.ctrl.Call(m, "Quantity")
ret0, _ := ret[0].(*big.Float) ret0, _ := ret[0].(decimal.Decimal)
return ret0 return ret0
} }
@@ -202,57 +356,19 @@ type MockRecordQuantityCall struct {
} }
// Return rewrite *gomock.Call.Return // Return rewrite *gomock.Call.Return
func (c *MockRecordQuantityCall) Return(arg0 *big.Float) *MockRecordQuantityCall { func (c *MockRecordQuantityCall) Return(arg0 decimal.Decimal) *MockRecordQuantityCall {
c.Call = c.Call.Return(arg0) c.Call = c.Call.Return(arg0)
return c return c
} }
// Do rewrite *gomock.Call.Do // Do rewrite *gomock.Call.Do
func (c *MockRecordQuantityCall) Do(f func() *big.Float) *MockRecordQuantityCall { func (c *MockRecordQuantityCall) Do(f func() decimal.Decimal) *MockRecordQuantityCall {
c.Call = c.Call.Do(f) c.Call = c.Call.Do(f)
return c return c
} }
// DoAndReturn rewrite *gomock.Call.DoAndReturn // DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordQuantityCall) DoAndReturn(f func() *big.Float) *MockRecordQuantityCall { func (c *MockRecordQuantityCall) DoAndReturn(f func() decimal.Decimal) *MockRecordQuantityCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// Side mocks base method.
func (m *MockRecord) Side() internal.Side {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Side")
ret0, _ := ret[0].(internal.Side)
return ret0
}
// Side indicates an expected call of Side.
func (mr *MockRecordMockRecorder) Side() *MockRecordSideCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Side", reflect.TypeOf((*MockRecord)(nil).Side))
return &MockRecordSideCall{Call: call}
}
// MockRecordSideCall wrap *gomock.Call
type MockRecordSideCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRecordSideCall) Return(arg0 internal.Side) *MockRecordSideCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRecordSideCall) Do(f func() internal.Side) *MockRecordSideCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordSideCall) DoAndReturn(f func() internal.Side) *MockRecordSideCall {
c.Call = c.Call.DoAndReturn(f) c.Call = c.Call.DoAndReturn(f)
return c return c
} }
@@ -296,10 +412,10 @@ func (c *MockRecordSymbolCall) DoAndReturn(f func() string) *MockRecordSymbolCal
} }
// Taxes mocks base method. // Taxes mocks base method.
func (m *MockRecord) Taxes() *big.Float { func (m *MockRecord) Taxes() decimal.Decimal {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Taxes") ret := m.ctrl.Call(m, "Taxes")
ret0, _ := ret[0].(*big.Float) ret0, _ := ret[0].(decimal.Decimal)
return ret0 return ret0
} }
@@ -316,19 +432,19 @@ type MockRecordTaxesCall struct {
} }
// Return rewrite *gomock.Call.Return // Return rewrite *gomock.Call.Return
func (c *MockRecordTaxesCall) Return(arg0 *big.Float) *MockRecordTaxesCall { func (c *MockRecordTaxesCall) Return(arg0 decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.Return(arg0) c.Call = c.Call.Return(arg0)
return c return c
} }
// Do rewrite *gomock.Call.Do // Do rewrite *gomock.Call.Do
func (c *MockRecordTaxesCall) Do(f func() *big.Float) *MockRecordTaxesCall { func (c *MockRecordTaxesCall) Do(f func() decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.Do(f) c.Call = c.Call.Do(f)
return c return c
} }
// DoAndReturn rewrite *gomock.Call.DoAndReturn // DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRecordTaxesCall) DoAndReturn(f func() *big.Float) *MockRecordTaxesCall { func (c *MockRecordTaxesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordTaxesCall {
c.Call = c.Call.DoAndReturn(f) c.Call = c.Call.DoAndReturn(f)
return c return c
} }
@@ -396,17 +512,17 @@ func (m *MockReportWriter) EXPECT() *MockReportWriterMockRecorder {
} }
// Write mocks base method. // Write mocks base method.
func (m *MockReportWriter) Write(arg0 internal.ReportItem) error { func (m *MockReportWriter) Write(arg0 context.Context, arg1 internal.ReportItem) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Write", arg0) ret := m.ctrl.Call(m, "Write", arg0, arg1)
ret0, _ := ret[0].(error) ret0, _ := ret[0].(error)
return ret0 return ret0
} }
// Write indicates an expected call of Write. // Write indicates an expected call of Write.
func (mr *MockReportWriterMockRecorder) Write(arg0 any) *MockReportWriterWriteCall { func (mr *MockReportWriterMockRecorder) Write(arg0, arg1 any) *MockReportWriterWriteCall {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockReportWriter)(nil).Write), arg0) call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockReportWriter)(nil).Write), arg0, arg1)
return &MockReportWriterWriteCall{Call: call} return &MockReportWriterWriteCall{Call: call}
} }
@@ -422,13 +538,241 @@ func (c *MockReportWriterWriteCall) Return(arg0 error) *MockReportWriterWriteCal
} }
// Do rewrite *gomock.Call.Do // Do rewrite *gomock.Call.Do
func (c *MockReportWriterWriteCall) Do(f func(internal.ReportItem) error) *MockReportWriterWriteCall { func (c *MockReportWriterWriteCall) Do(f func(context.Context, internal.ReportItem) error) *MockReportWriterWriteCall {
c.Call = c.Call.Do(f) c.Call = c.Call.Do(f)
return c return c
} }
// DoAndReturn rewrite *gomock.Call.DoAndReturn // DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockReportWriterWriteCall) DoAndReturn(f func(internal.ReportItem) error) *MockReportWriterWriteCall { func (c *MockReportWriterWriteCall) DoAndReturn(f func(context.Context, internal.ReportItem) error) *MockReportWriterWriteCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockRecordEncoder is a mock of RecordEncoder interface.
type MockRecordEncoder struct {
ctrl *gomock.Controller
recorder *MockRecordEncoderMockRecorder
isgomock struct{}
}
// MockRecordEncoderMockRecorder is the mock recorder for MockRecordEncoder.
type MockRecordEncoderMockRecorder struct {
mock *MockRecordEncoder
}
// NewMockRecordEncoder creates a new mock instance.
func NewMockRecordEncoder(ctrl *gomock.Controller) *MockRecordEncoder {
mock := &MockRecordEncoder{ctrl: ctrl}
mock.recorder = &MockRecordEncoderMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockRecordEncoder) EXPECT() *MockRecordEncoderMockRecorder {
return m.recorder
}
// MarshalRecord mocks base method.
func (m *MockRecordEncoder) MarshalRecord(arg0 context.Context, arg1 internal.Record) (json.RawMessage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
ret0, _ := ret[0].(json.RawMessage)
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 json.RawMessage, 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) (json.RawMessage, 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) (json.RawMessage, 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 json.RawMessage) (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, json.RawMessage) (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, json.RawMessage) (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) (json.RawMessage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "MarshalRecord", arg0, arg1)
ret0, _ := ret[0].(json.RawMessage)
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 json.RawMessage, 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) (json.RawMessage, 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) (json.RawMessage, error)) *MockRecordSerializerMarshalRecordCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// UnmarshalRecord mocks base method.
func (m *MockRecordSerializer) UnmarshalRecord(arg0 context.Context, arg1 json.RawMessage) (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, json.RawMessage) (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, json.RawMessage) (internal.Record, error)) *MockRecordSerializerUnmarshalRecordCall {
c.Call = c.Call.DoAndReturn(f) c.Call = c.Call.DoAndReturn(f)
return c return c
} }
+31
View File
@@ -0,0 +1,31 @@
package internal
type Nature string
const (
// NatureUnknown is the zero value of Nature type
NatureUnknown Nature = ""
// NatureG01 describes selling of stocks per table VII: Alienação onerosa de ações/partes sociais
NatureG01 Nature = "G01"
// NatureG20 describes selling units in investment funds (including ETFs) as per table VII:
// Resgates ou alienação de unidades de participação ou liquidação de fundos de investimento
NatureG20 Nature = "G20"
)
func (n Nature) String() string {
if n.Valid() {
return string(n)
}
return "unknown"
}
func (n Nature) Valid() bool {
switch n {
case NatureG01, NatureG20:
return true
default:
return false
}
}
+42
View File
@@ -0,0 +1,42 @@
package internal_test
import (
"testing"
"github.com/nmoniz/any2anexoj/internal"
)
func TestNature_String(t *testing.T) {
tests := []struct {
name string
nature internal.Nature
want string
}{
{
name: "return unknown for empty",
want: "unknown",
},
{
name: "return unknown for bad value",
want: "unknown",
},
{
name: "return G01",
nature: internal.NatureG01,
want: "G01",
},
{
name: "return G20",
nature: internal.NatureG20,
want: "G20",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.nature.String()
if tt.want != got {
t.Fatalf("want %q but got %q", tt.want, got)
}
})
}
}
+159
View File
@@ -0,0 +1,159 @@
package ofigi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
"github.com/biter777/countries"
"golang.org/x/time/rate"
)
var OpenFIGIAPIKeyHeader = http.CanonicalHeaderKey("X-OPENFIGI-APIKEY")
// Client is a thin adapter for the openfigi.com api.
type Client struct {
client *http.Client
apiKey string
mappingLimiter *rate.Limiter
mu sync.RWMutex
// TODO: there's no eviction policy at the moment as this is only used by short-lived application
// which processes a relatively small amount of records. We need to consider using an external
// cache lib (like golang-lru or go-cache) if this becomes a problem or implement eviction
// ourselves.
securityTypeCache map[string]string
}
// NewOpenFIGI creates an OpenFIGI client that uses the API key if provided
func NewOpenFIGI(c *http.Client, apiKey string) *Client {
// Rate limits as per https://www.openfigi.com/api/documentation#rate-limits
limiter := rate.NewLimiter(rate.Every(time.Minute), 25)
if len(apiKey) > 0 {
slog.Debug("OpenFIGI client: created with API Key rate limits")
limiter = rate.NewLimiter(rate.Every(time.Second*6), 25)
} else {
slog.Debug("OpenFIGI client: created with puplic rate limits")
}
return &Client{
client: c,
apiKey: apiKey,
mappingLimiter: limiter,
securityTypeCache: make(map[string]string),
}
}
func (of *Client) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) {
of.mu.RLock()
if secType, ok := of.securityTypeCache[isin]; ok {
of.mu.RUnlock()
slog.Debug("OpenFIGI client: SecurityTypeByISIN cache hit",
slog.String("isin", isin),
slog.String("security_type", secType))
return secType, nil
}
of.mu.RUnlock()
slog.Debug("OpenFIGI client: SecurityTypeByISIN cache miss",
slog.String("isin", isin))
of.mu.Lock()
defer of.mu.Unlock()
// we check again because there could be more than one concurrent cache miss and we want only one
// of them to result in an actual request. When the first one releases the lock the following
// reads will hit the cache.
if secType, ok := of.securityTypeCache[isin]; ok {
return secType, nil
}
if len(isin) != 12 || countries.ByName(isin[:2]) == countries.Unknown {
return "", fmt.Errorf("invalid ISIN: %s", isin)
}
rawBody, err := json.Marshal([]mappingRequestBody{{
IDType: "ID_ISIN",
IDValue: isin,
}})
if err != nil {
return "", fmt.Errorf("marshal mapping request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openfigi.com/v3/mapping", bytes.NewBuffer(rawBody))
if err != nil {
return "", fmt.Errorf("create mapping request: %w", err)
}
req.Header.Add("Content-Type", "application/json")
if len(of.apiKey) > 0 {
req.Header.Add(OpenFIGIAPIKeyHeader, of.apiKey)
}
if !of.mappingLimiter.Allow() {
slog.Debug("OpenFIGI client: mapping limiter waiting for rate limiter capacity")
}
err = of.mappingLimiter.Wait(ctx)
if err != nil {
return "", fmt.Errorf("wait for mapping request capacity: %w", err)
}
res, err := of.client.Do(req)
if err != nil {
return "", fmt.Errorf("make mapping request: %w", err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return "", fmt.Errorf("bad mapping response status code: %s", res.Status)
}
var resBody []mappingResponseBody
err = json.NewDecoder(res.Body).Decode(&resBody)
if err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
if len(resBody) == 0 {
return "", fmt.Errorf("missing top-level elements")
}
if len(resBody[0].Data) == 0 {
return "", fmt.Errorf("missing data elements")
}
// It is not possible that an isin is assign to different security types, therefore we can assume
// all entries have the same securityType value.
secType := resBody[0].Data[0].SecurityType
if secType == "" {
return "", fmt.Errorf("empty security type returned for ISIN: %s", isin)
}
of.securityTypeCache[isin] = secType
slog.Debug("OpenFIGI client: SecurityTypeByISIN cached mapping",
slog.String("isin", isin),
slog.String("security_type", secType))
return secType, nil
}
type mappingRequestBody struct {
IDType string `json:"idType"`
IDValue string `json:"idValue"`
}
type mappingResponseBody struct {
Data []struct {
FIGI string `json:"figi"`
SecurityType string `json:"securityType"`
Ticker string `json:"ticker"`
} `json:"data"`
}
+230
View File
@@ -0,0 +1,230 @@
package ofigi_test
import (
"bytes"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal/ofigi"
)
func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) {
tests := []struct {
name string // description of this test case
client *http.Client
isin string
want string
wantErr bool
}{
{
name: "all good",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"figi":"BBG000BJJR23","name":"AIRBUS SE","ticker":"EADSF","exchCode":"US","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"},{"figi":"BBG000BJJXJ2","name":"AIRBUS SE","ticker":"EADSF","exchCode":"PQ","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"}]}]`)),
}, nil
}),
isin: "NL0000235190",
want: "Common Stock",
},
{
name: "bad status code",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusTooManyRequests),
StatusCode: http.StatusTooManyRequests,
}, nil
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "bad json",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"bad": "json"}`)),
}, nil
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "empty top-level",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[]`)),
}, nil
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "empty data elements",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[]}]`)),
}, nil
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "empty securityType",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":""}]}]`)),
}, nil
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "client error",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("boom")
}),
isin: "NL0000235190",
wantErr: true,
},
{
name: "empty isin",
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
t.Fatalf("should not make api request")
return nil, nil
}),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
of := ofigi.NewOpenFIGI(tt.client, "")
got, gotErr := of.SecurityTypeByISIN(t.Context(), tt.isin)
if gotErr != nil {
if !tt.wantErr {
t.Errorf("want success but failed: %v", gotErr)
}
return
}
if tt.wantErr {
t.Fatal("want error but none")
}
if tt.want != got {
t.Fatalf("want security type to be %s but got %s", tt.want, got)
}
})
}
}
func TestOpenFIGI_SecurityTypeByISIN_Cache(t *testing.T) {
var alreadyCalled bool
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
if alreadyCalled {
t.Fatalf("want requests to be cached")
}
alreadyCalled = true
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
}, nil
})
of := ofigi.NewOpenFIGI(c, "")
got, gotErr := of.SecurityTypeByISIN(t.Context(), "NL0000235190")
if gotErr != nil {
t.Fatalf("want 1st success call but got error: %v", gotErr)
}
if got != "Common Stock" {
t.Fatalf("want 1st securityType to be %q but got %q", "Common Stock", got)
}
got, gotErr = of.SecurityTypeByISIN(t.Context(), "NL0000235190")
if gotErr != nil {
t.Fatalf("want 2nd success call but got error: %v", gotErr)
}
if got != "Common Stock" {
t.Fatalf("want 2nd securityType to be %q but got %q", "Common Stock", got)
}
}
func TestOpenFIGI_SecurityTypeByISIN_APIKey(t *testing.T) {
t.Run("with API key", func(t *testing.T) {
wantAPIKey := "123abc-456xyz"
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
value, ok := req.Header[ofigi.OpenFIGIAPIKeyHeader]
if !ok {
t.Fatalf("want %q header but got none: %v", ofigi.OpenFIGIAPIKeyHeader, req.Header)
}
if len(value) != 1 {
t.Fatalf("want exactly one %q header value but got %d", ofigi.OpenFIGIAPIKeyHeader, len(value))
}
if value[0] != wantAPIKey {
t.Fatalf("want %q header value %q but got %q", ofigi.OpenFIGIAPIKeyHeader, wantAPIKey, value[0])
}
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
}, nil
})
of := ofigi.NewOpenFIGI(c, wantAPIKey)
_, err := of.SecurityTypeByISIN(t.Context(), "US1234567890")
if err != nil {
t.Fatalf("want success but got an error: %s", err)
}
})
t.Run("without API key", func(t *testing.T) {
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
_, ok := req.Header[ofigi.OpenFIGIAPIKeyHeader]
if ok {
t.Fatalf("want no %s header but got one", ofigi.OpenFIGIAPIKeyHeader)
}
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
}, nil
})
of := ofigi.NewOpenFIGI(c, "")
_, err := of.SecurityTypeByISIN(t.Context(), "US1234567890")
if err != nil {
t.Fatalf("want success but got an error: %s", err)
}
})
}
type RoundTripFunc func(req *http.Request) (*http.Response, error)
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func NewTestClient(t testing.TB, fn RoundTripFunc) *http.Client {
t.Helper()
return &http.Client{
Timeout: time.Second,
Transport: fn,
}
}
-64
View File
@@ -1,64 +0,0 @@
package internal
import (
"container/list"
"math/big"
"time"
)
type Record interface {
Symbol() string
Side() Side
Price() *big.Float
Quantity() *big.Float
Timestamp() time.Time
Fees() *big.Float
Taxes() *big.Float
}
type RecordQueue struct {
l *list.List
}
// Push inserts the Record at the back of the queue. If pushing a nil Record then it's a no-op.
func (rq *RecordQueue) Push(r Record) {
if r == nil {
return
}
if rq == nil {
// This would cause a panic anyway so, we panic with a more meaningful message
panic("Push to nil RecordQueue")
}
if rq.l == nil {
rq.l = list.New()
}
rq.l.PushBack(r)
}
// Pop removes and returns the first Record of the list as the first return value. If the list is
// empty returns falso on the 2nd return value, true otherwise.
func (rq *RecordQueue) Pop() (Record, bool) {
if rq == nil || rq.l == nil {
return nil, false
}
el := rq.l.Front()
if el == nil {
return nil, false
}
val := rq.l.Remove(el)
return val.(Record), true
}
func (rq *RecordQueue) Len() int {
if rq == nil || rq.l == nil {
return 0
}
return rq.l.Len()
}
-90
View File
@@ -1,90 +0,0 @@
package internal
import (
"testing"
)
func TestRecordQueue(t *testing.T) {
var recCount int
newRecord := func() Record {
recCount++
return testRecord{
id: recCount,
}
}
var rq RecordQueue
if rq.Len() != 0 {
t.Fatalf("zero value should have zero lenght")
}
_, ok := rq.Pop()
if ok {
t.Fatalf("Pop() should return (_,false) on a zero value")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("pushing nil should be a no-op")
}
rq.Push(newRecord())
if rq.Len() != 1 {
t.Fatalf("pushing 1st record should result in lenght of 1")
}
rq.Push(newRecord())
if rq.Len() != 2 {
t.Fatalf("pushing 2nd record should result in lenght of 2")
}
rec, ok := rq.Pop()
if !ok {
t.Fatalf("Pop() should return (_,true) when the list is not empty")
}
if rec, ok := rec.(testRecord); ok {
if rec.id != 1 {
t.Fatalf("Pop() should return the first record pushed but returned %d", rec.id)
}
} else {
t.Fatalf("Pop() should return the original record")
}
}
func TestRecordQueueNilReceiver(t *testing.T) {
var rq *RecordQueue
if rq.Len() > 0 {
t.Fatalf("nil receiver should have zero lenght")
}
_, ok := rq.Pop()
if ok {
t.Fatalf("Pop() on a nil receiver should return (_,false)")
}
rq.Push(nil)
if rq.Len() != 0 {
t.Fatalf("Push(nil) on a nil receiver should be a no-op")
}
defer func() {
r := recover()
if r == nil {
t.Fatalf("expected a panic but got nothing")
}
expMsg := "Push to nil RecordQueue"
if msg, ok := r.(string); !ok || msg != expMsg {
t.Fatalf(`want panic message %q but got "%v"`, expMsg, r)
}
}()
rq.Push(testRecord{})
}
type testRecord struct {
Record
id int
}
+211 -2
View File
@@ -1,15 +1,224 @@
package internal package internal
import ( import (
"context"
"errors" "errors"
"fmt"
"io"
"log/slog"
"time"
"github.com/shopspring/decimal"
) )
type Record interface {
Symbol() string
Nature() Nature
BrokerCountry() int64
AssetCountry() int64
Kind() Kind
Price() decimal.Decimal
Quantity() decimal.Decimal
Timestamp() time.Time
Fees() decimal.Decimal
Taxes() decimal.Decimal
}
type RecordReader interface { type RecordReader interface {
// ReadRecord should return Records until an error is found. // ReadRecord should return Records until an error is found.
ReadRecord() (Record, error) ReadRecord(context.Context) (Record, error)
}
type ReportItem struct {
Symbol string
Nature Nature
BrokerCountry int64
AssetCountry int64
BuyValue decimal.Decimal
BuyTimestamp time.Time
SellValue decimal.Decimal
SellTimestamp time.Time
Fees decimal.Decimal
Taxes decimal.Decimal
}
func (ri ReportItem) RealisedPnL() decimal.Decimal {
return ri.SellValue.Sub(ri.BuyValue)
} }
type ReportWriter interface { type ReportWriter interface {
// ReportWriter writes report items // ReportWriter writes report items
Write(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.
type Selector func(Record) bool
func WithSelector(s Selector) Option {
return func(o *optionals) {
o.selector = s
}
}
type Store interface {
Load(context.Context) (map[string]*FillerQueue, error)
Save(context.Context, map[string]*FillerQueue) error
}
func WithStore(s Store) Option {
return func(o *optionals) {
o.store = s
}
}
// BuildReport reads records from a RecordReader and, if the record passes the Selector, it is
// processed into the ReportWriter.
func BuildReport(ctx context.Context, reader RecordReader, writer ReportWriter, options ...Option) error {
optionals := applyOptions(optionals{
selector: Any(),
store: EphemeralStore{},
}, options)
buys, err := optionals.store.Load(ctx)
if err != nil {
return fmt.Errorf("loading state: %w", err)
}
var (
recordsCount int64
lastTimestamp time.Time
progTicker = time.NewTicker(10 * time.Second)
)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-progTicker.C:
slog.InfoContext(
ctx, "Progress update",
slog.Int64("records_count", recordsCount),
slog.Time("last_record_timestamp", lastTimestamp),
)
default:
rec, err := reader.ReadRecord(ctx)
if err != nil {
if errors.Is(err, io.EOF) {
err = optionals.store.Save(ctx, buys)
if err != nil {
return fmt.Errorf("saving state: %w", err)
}
return nil
}
return err
}
if !rec.Kind().Valid() {
return fmt.Errorf("found invalid Kind(%d)", rec.Kind())
}
lastTimestamp = rec.Timestamp()
recordsCount++
buyQueue, ok := buys[rec.Symbol()]
if !ok {
buyQueue = new(FillerQueue)
buys[rec.Symbol()] = buyQueue
}
err = processRecord(ctx, buyQueue, rec, optionals.selector, writer)
if err != nil {
return fmt.Errorf("processing record: %w", err)
}
}
}
}
// processRecord either adds buys to the queue or consumes buys from the queue when processing a
// sell record.
func processRecord(ctx context.Context, q *FillerQueue, rec Record, sel Selector, writer ReportWriter) error {
slog.Debug(
"Report: processing record",
slog.String("symbol", rec.Symbol()),
slog.String("side", rec.Kind().String()),
)
switch rec.Kind() {
case KindBuy:
// Selectors are only applied when processing sell records for performance reasons. It's much
// cheaper to just accumulate buys and only actually inspect any records once a sell happens. This
// avoids potential network requests to for every single record.
q.Push(NewFiller(rec))
case KindSell:
if !sel(rec) {
slog.Debug(
"Report: skipping record",
slog.String("symbol", rec.Symbol()),
slog.String("side", rec.Kind().String()),
)
return nil
}
unmatchedQty := rec.Quantity()
for unmatchedQty.IsPositive() {
buy, ok := q.Peek()
if !ok {
return ErrInsufficientBoughtVolume
}
matchedQty, filled := buy.Fill(unmatchedQty)
if filled {
_, ok := q.Pop()
if !ok {
return fmt.Errorf("pop empty filler queue")
}
}
unmatchedQty = unmatchedQty.Sub(matchedQty)
buyValue := matchedQty.Mul(buy.Price())
sellValue := matchedQty.Mul(rec.Price())
err := writer.Write(ctx, ReportItem{
Symbol: rec.Symbol(),
BrokerCountry: rec.BrokerCountry(),
AssetCountry: rec.AssetCountry(),
BuyValue: buyValue,
BuyTimestamp: buy.Timestamp(),
SellValue: sellValue,
SellTimestamp: rec.Timestamp(),
Fees: buy.Fees().Add(rec.Fees()),
Taxes: buy.Taxes().Add(rec.Taxes()),
Nature: buy.Nature(),
})
if err != nil {
return fmt.Errorf("write report item: %w", err)
}
}
case KindSplit:
q.AdjustForSplit(rec.Quantity())
default:
return fmt.Errorf("unknown side: %v", rec.Kind())
}
return nil
} }
+185
View File
@@ -0,0 +1,185 @@
package internal_test
import (
"context"
"fmt"
"io"
"reflect"
"testing"
"time"
"github.com/biter777/countries"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/mocks"
"github.com/shopspring/decimal"
"go.uber.org/mock/gomock"
)
func TestBuildReport(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),
mockRecord(ctrl, 25.0, 10.0, internal.KindSell, now.Add(1)),
}
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
} else {
return nil, io.EOF
}
}).Times(3)
writer := mocks.NewMockReportWriter(ctrl)
writer.EXPECT().Write(gomock.Any(), eqReportItem(internal.ReportItem{
BuyValue: decimal.NewFromFloat(200.0),
BuyTimestamp: now,
SellValue: decimal.NewFromFloat(250.0),
SellTimestamp: now.Add(1),
Fees: decimal.Decimal{},
Taxes: decimal.Decimal{},
})).Times(1)
gotErr := internal.BuildReport(t.Context(), reader, writer)
if gotErr != nil {
t.Fatalf("got unexpected err: %v", gotErr)
}
}
func mockRecord(ctrl *gomock.Controller, price, quantity float64, kind internal.Kind, ts time.Time) *mocks.MockRecord {
rec := mocks.NewMockRecord(ctrl)
rec.EXPECT().Symbol().Return("TEST").AnyTimes()
rec.EXPECT().BrokerCountry().Return(int64(countries.PT)).AnyTimes()
rec.EXPECT().AssetCountry().Return(int64(countries.USA)).AnyTimes()
rec.EXPECT().Price().Return(decimal.NewFromFloat(price)).AnyTimes()
rec.EXPECT().Quantity().Return(decimal.NewFromFloat(quantity)).AnyTimes()
rec.EXPECT().Kind().Return(kind).AnyTimes()
rec.EXPECT().Timestamp().Return(ts).AnyTimes()
rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes()
rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes()
rec.EXPECT().Nature().Return(internal.NatureG01).AnyTimes()
return rec
}
func eqReportItem(ri internal.ReportItem) ReportItemMatcher {
return ReportItemMatcher{
ReportItem: ri,
}
}
type ReportItemMatcher struct {
internal.ReportItem
}
// Matches implements gomock.Matcher.
func (m ReportItemMatcher) Matches(x any) bool {
if x == nil {
return false
}
switch other := x.(type) {
case internal.ReportItem:
return m.BuyValue.Equal(other.BuyValue) &&
m.BuyTimestamp.Equal(other.BuyTimestamp) &&
m.SellValue.Equal(other.SellValue) &&
m.SellTimestamp.Equal(other.SellTimestamp) &&
m.Fees.Equal(other.Fees) &&
m.Taxes.Equal(other.Taxes)
default:
return false
}
}
func (m ReportItemMatcher) String() string {
return fmt.Sprintf("is equivalent to %v", m.ReportItem)
}
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")
}
}
-125
View File
@@ -1,125 +0,0 @@
package internal
import (
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"sync"
)
// Reporter consumes each record to produce ReportItem.
type Reporter struct {
reader RecordReader
}
func NewReporter(rr RecordReader) *Reporter {
return &Reporter{
reader: rr,
}
}
func (r *Reporter) Run() error {
forewarders := make(map[string]chan Record)
aggregator := make(chan processResult)
defer close(aggregator)
go func() {
for result := range aggregator {
fmt.Printf("%v\n", result)
}
}()
wg := sync.WaitGroup{}
defer func() {
wg.Wait()
}()
for {
rec, err := r.reader.ReadRecord()
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
router, ok := forewarders[rec.Symbol()]
if !ok {
router = make(chan Record, 1)
defer close(router)
wg.Go(func() {
processRecords(router, aggregator)
})
forewarders[rec.Symbol()] = router
}
router <- rec
}
}
func processRecords(records <-chan Record, results chan<- processResult) {
var q RecordQueue
for rec := range records {
switch rec.Side() {
case SideBuy:
q.Push(rec)
case SideSell:
unmatchedQty := new(big.Float).Copy(rec.Quantity())
zero := new(big.Float)
for unmatchedQty.Cmp(zero) > 0 {
buy, ok := q.Pop()
if !ok {
results <- processResult{
err: ErrSellWithoutBuy,
}
return
}
var matchedQty *big.Float
if buy.Quantity().Cmp(unmatchedQty) > 0 {
matchedQty = unmatchedQty
buy.Quantity().Sub(buy.Quantity(), unmatchedQty)
} else {
matchedQty = buy.Quantity()
}
unmatchedQty.Sub(unmatchedQty, matchedQty)
sellValue := new(big.Float).Mul(matchedQty, rec.Price())
buyValue := new(big.Float).Mul(matchedQty, buy.Price())
realisedPnL := new(big.Float).Sub(sellValue, buyValue)
slog.Info("Realised PnL",
slog.Any("Symbol", rec.Symbol()),
slog.Any("PnL", realisedPnL),
slog.Any("Timestamp", rec.Timestamp()))
results <- processResult{
item: ReportItem{},
}
}
default:
results <- processResult{
err: fmt.Errorf("unknown side: %v", rec.Side()),
}
return
}
}
}
type processResult struct {
item ReportItem
err error
}
type ReportItem struct{}
var ErrSellWithoutBuy = fmt.Errorf("found sell without bought volume")
-42
View File
@@ -1,42 +0,0 @@
package internal_test
import (
"io"
"math/big"
"testing"
"git.naterciomoniz.net/applications/broker2anexoj/internal"
"git.naterciomoniz.net/applications/broker2anexoj/internal/mocks"
"go.uber.org/mock/gomock"
)
func TestReporter_Run(t *testing.T) {
ctrl := gomock.NewController(t)
rec := mocks.NewMockRecord(ctrl)
rec.EXPECT().Price().Return(big.NewFloat(1.25)).AnyTimes()
rec.EXPECT().Quantity().Return(big.NewFloat(10)).AnyTimes()
rec.EXPECT().Side().Return(internal.SideBuy).AnyTimes()
rec.EXPECT().Symbol().Return("TEST").AnyTimes()
reader := mocks.NewMockRecordReader(ctrl)
records := []internal.Record{
rec,
rec,
}
reader.EXPECT().ReadRecord().DoAndReturn(func() (internal.Record, error) {
if len(records) > 0 {
r := records[0]
records = records[1:]
return r, nil
} else {
return nil, io.EOF
}
}).AnyTimes()
reporter := internal.NewReporter(reader)
gotErr := reporter.Run()
if gotErr != nil {
t.Fatalf("got unexpected err: %v", gotErr)
}
}
+117
View File
@@ -0,0 +1,117 @@
package internal
import (
"fmt"
"strconv"
"strings"
)
func Any() Selector {
return func(r Record) bool { return true }
}
func And(a, b Selector) Selector {
return func(r Record) bool {
return a(r) && b(r)
}
}
// OnlyNature will only select records with the given Nature n (G01, G20, etc...).
func OnlyNature(n Nature) Selector {
return func(r Record) bool {
return r.Nature() == n
}
}
// OnlyAssetCountry will only select records with the given ISO code c (620 for Portugal, 196 for
// Cyprus, etc...).
func OnlyAssetCountry(c int64) Selector {
return func(r Record) bool {
return r.AssetCountry() == c
}
}
// selectorParser is a function that parses a selector value string into a Selector
type selectorParser func(string) (Selector, error)
// parsers maps selector keys to their parser functions
var parsers = map[string]selectorParser{
"code": parseNature,
"assetCountry": parseAssetCountry,
}
func parseNature(value string) (Selector, error) {
if value == "" {
return nil, fmt.Errorf("code selector requires a non-empty value")
}
nature := Nature(value)
if !nature.Valid() {
return nil, fmt.Errorf("invalid nature code %q", value)
}
return OnlyNature(nature), nil
}
func parseAssetCountry(value string) (Selector, error) {
if value == "" {
return nil, fmt.Errorf("assetCountry selector requires a non-empty value")
}
i, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, fmt.Errorf("assetCountry value must be a valid integer: %w", err)
}
return OnlyAssetCountry(i), nil
}
// ParseSelectors parses a list of selector strings into a composed Selector.
// Each selector string must be in the format "key:value" where key is one of:
// - code: filter by nature (e.g., "code:G01")
// - assetCountry: filter by asset country code (e.g., "assetCountry:840")
//
// Multiple selectors are combined with AND logic.
// If no selectors are provided, returns Any() which matches all records.
func ParseSelectors(sl []string) (Selector, error) {
if len(sl) == 0 {
return Any(), nil
}
// Parse the first selector
first, err := parseSingleSelector(sl[0])
if err != nil {
return nil, err
}
// If there's only one, return it
if len(sl) == 1 {
return first, nil
}
// Recursively parse the rest and combine with AND
rest, err := ParseSelectors(sl[1:])
if err != nil {
return nil, err
}
return And(first, rest), nil
}
func parseSingleSelector(s string) (Selector, error) {
key, value, found := strings.Cut(s, ":")
if !found {
return nil, fmt.Errorf("invalid selector format %q: must be 'key:value'", s)
}
parser, ok := parsers[key]
if !ok {
return nil, fmt.Errorf("unknown selector key %q: supported keys are %v", key, supportedKeys())
}
return parser(value)
}
func supportedKeys() []string {
keys := make([]string, 0, len(parsers))
for k := range parsers {
keys = append(keys, k)
}
return keys
}
+434
View File
@@ -0,0 +1,434 @@
package internal_test
import (
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal"
)
type testRecord struct {
symbol string
nature internal.Nature
brokerCountry int64
assetCountry int64
side internal.Kind
price decimal.Decimal
quantity decimal.Decimal
timestamp time.Time
fees decimal.Decimal
taxes decimal.Decimal
}
func (m testRecord) Symbol() string { return m.symbol }
func (m testRecord) Nature() internal.Nature { return m.nature }
func (m testRecord) BrokerCountry() int64 { return m.brokerCountry }
func (m testRecord) AssetCountry() int64 { return m.assetCountry }
func (m testRecord) Kind() internal.Kind { return m.side }
func (m testRecord) Price() decimal.Decimal { return m.price }
func (m testRecord) Quantity() decimal.Decimal { return m.quantity }
func (m testRecord) Timestamp() time.Time { return m.timestamp }
func (m testRecord) Fees() decimal.Decimal { return m.fees }
func (m testRecord) Taxes() decimal.Decimal { return m.taxes }
func TestAny(t *testing.T) {
selector := internal.Any()
tests := []struct {
name string
record internal.Record
want bool
}{
{
name: "returns true for any record",
record: testRecord{
symbol: "AAPL",
nature: internal.NatureG01,
assetCountry: 1,
},
want: true,
},
{
name: "returns true for record with unknown nature",
record: testRecord{
symbol: "MSFT",
nature: internal.NatureUnknown,
assetCountry: 2,
},
want: true,
},
{
name: "returns true for empty record",
record: testRecord{},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := selector(tt.record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestOnlyNature(t *testing.T) {
tests := []struct {
name string
nature internal.Nature
record internal.Record
want bool
}{
{
name: "matches G01 nature",
nature: internal.NatureG01,
record: testRecord{
symbol: "AAPL",
nature: internal.NatureG01,
},
want: true,
},
{
name: "matches G20 nature",
nature: internal.NatureG20,
record: testRecord{
symbol: "ETF",
nature: internal.NatureG20,
},
want: true,
},
{
name: "does not match different nature",
nature: internal.NatureG01,
record: testRecord{
symbol: "AAPL",
nature: internal.NatureG20,
},
want: false,
},
{
name: "does not match unknown nature",
nature: internal.NatureG01,
record: testRecord{
symbol: "AAPL",
nature: internal.NatureUnknown,
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector := internal.OnlyNature(tt.nature)
got := selector(tt.record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestOnlyAssetCountry(t *testing.T) {
tests := []struct {
name string
country int64
record internal.Record
want bool
}{
{
name: "matches asset country",
country: 840, // USA
record: testRecord{
symbol: "AAPL",
assetCountry: 840,
},
want: true,
},
{
name: "matches different country",
country: 826, // UK
record: testRecord{
symbol: "BP",
assetCountry: 826,
},
want: true,
},
{
name: "does not match different asset country",
country: 840,
record: testRecord{
symbol: "BP",
assetCountry: 826,
},
want: false,
},
{
name: "does not match zero country",
country: 0,
record: testRecord{
symbol: "AAPL",
assetCountry: 840,
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector := internal.OnlyAssetCountry(tt.country)
got := selector(tt.record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestAnd(t *testing.T) {
record := testRecord{}
tests := []struct {
name string
selectorA internal.Selector
selectorB internal.Selector
want bool
}{
{
name: "both selectors return true",
selectorA: func(internal.Record) bool { return true },
selectorB: func(internal.Record) bool { return true },
want: true,
},
{
name: "first selector returns true, second returns false",
selectorA: func(internal.Record) bool { return true },
selectorB: func(internal.Record) bool { return false },
want: false,
},
{
name: "first selector returns false, second returns true",
selectorA: func(internal.Record) bool { return false },
selectorB: func(internal.Record) bool { return true },
want: false,
},
{
name: "both selectors return false",
selectorA: func(internal.Record) bool { return false },
selectorB: func(internal.Record) bool { return false },
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector := internal.And(tt.selectorA, tt.selectorB)
got := selector(record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestParseSelectors_Empty(t *testing.T) {
selector, err := internal.ParseSelectors([]string{})
if err != nil {
t.Fatalf("unexpected error for empty selectors: %v", err)
}
record := testRecord{
nature: internal.NatureG01,
assetCountry: 840,
}
if !selector(record) {
t.Fatalf("empty selectors should match all records")
}
}
func TestParseSelectors_OnlyNature(t *testing.T) {
tests := []struct {
name string
selector string
nature internal.Nature
want bool
}{
{
name: "matches G01",
selector: "code:G01",
nature: internal.NatureG01,
want: true,
},
{
name: "matches G20",
selector: "code:G20",
nature: internal.NatureG20,
want: true,
},
{
name: "does not match different nature",
selector: "code:G01",
nature: internal.NatureG20,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector, err := internal.ParseSelectors([]string{tt.selector})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
record := testRecord{
nature: tt.nature,
assetCountry: 840,
}
got := selector(record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestParseSelectors_OnlyAssetCountry(t *testing.T) {
tests := []struct {
name string
selector string
assetCountry int64
want bool
}{
{
name: "matches USA",
selector: "assetCountry:840",
assetCountry: 840,
want: true,
},
{
name: "matches UK",
selector: "assetCountry:826",
assetCountry: 826,
want: true,
},
{
name: "does not match different country",
selector: "assetCountry:840",
assetCountry: 826,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector, err := internal.ParseSelectors([]string{tt.selector})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
record := testRecord{
nature: internal.NatureG01,
assetCountry: tt.assetCountry,
}
got := selector(record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestParseSelectors_Multiple(t *testing.T) {
tests := []struct {
name string
selectors []string
nature internal.Nature
assetCountry int64
want bool
}{
{
name: "both selectors match",
selectors: []string{"code:G01", "assetCountry:840"},
nature: internal.NatureG01,
assetCountry: 840,
want: true,
},
{
name: "first selector matches, second does not",
selectors: []string{"code:G01", "assetCountry:826"},
nature: internal.NatureG01,
assetCountry: 840,
want: false,
},
{
name: "first selector does not match, second does",
selectors: []string{"code:G20", "assetCountry:840"},
nature: internal.NatureG01,
assetCountry: 840,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selector, err := internal.ParseSelectors(tt.selectors)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
record := testRecord{
nature: tt.nature,
assetCountry: tt.assetCountry,
}
got := selector(record)
if got != tt.want {
t.Fatalf("want %v but got %v", tt.want, got)
}
})
}
}
func TestParseSelectors_InvalidAssetCountry(t *testing.T) {
t.Run("returns error for non-numeric asset country", func(t *testing.T) {
_, err := internal.ParseSelectors([]string{"assetCountry:notanumber"})
if err == nil {
t.Fatalf("expected error for non-numeric asset country")
}
})
}
func TestParseSelectors_UnknownSelector(t *testing.T) {
t.Run("returns error for unknown selector type", func(t *testing.T) {
_, err := internal.ParseSelectors([]string{"unknown:value"})
if err == nil {
t.Fatalf("expected error for unknown selector type")
}
})
}
func TestParseSelectors_EmptyValue(t *testing.T) {
t.Run("rejects code with empty value", func(t *testing.T) {
_, err := internal.ParseSelectors([]string{"code:"})
if err == nil {
t.Fatalf("expected error for empty code value")
}
})
t.Run("rejects assetCountry with empty value", func(t *testing.T) {
_, err := internal.ParseSelectors([]string{"assetCountry:"})
if err == nil {
t.Fatalf("expected error for empty assetCountry value")
}
})
}
func TestParseSelectors_MissingColon(t *testing.T) {
t.Run("rejects input without colon", func(t *testing.T) {
_, err := internal.ParseSelectors([]string{"code"})
if err == nil {
t.Fatalf("expected error for input without colon")
}
})
}
-28
View File
@@ -1,28 +0,0 @@
package internal
type Side uint
const (
SideUnknown Side = iota
SideBuy
SideSell
)
func (d Side) String() string {
switch d {
case SideBuy:
return "buy"
case SideSell:
return "sell"
default:
return "unknown"
}
}
func (d Side) IsBuy() bool {
return d == SideBuy
}
func (d Side) IsSell() bool {
return d == SideSell
}
-60
View File
@@ -1,60 +0,0 @@
package internal
import "testing"
func TestSide_String(t *testing.T) {
tests := []struct {
name string
side Side
want string
}{
{"buy", SideBuy, "buy"},
{"sell", SideSell, "sell"},
{"unknown", SideUnknown, "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.side.String(); got != tt.want {
t.Errorf("want Side.String() to be %v but got %v", tt.want, got)
}
})
}
}
func TestSide_IsBuy(t *testing.T) {
tests := []struct {
name string
side Side
want bool
}{
{"buy", SideBuy, true},
{"sell", SideSell, false},
{"unknown", SideUnknown, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.side.IsBuy(); got != tt.want {
t.Errorf("want Side.IsBuy() to be %v but got %v", tt.want, got)
}
})
}
}
func TestSide_IsSell(t *testing.T) {
tests := []struct {
name string
side Side
want bool
}{
{"buy", SideBuy, false},
{"sell", SideSell, true},
{"unknown", SideUnknown, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.side.IsSell(); got != tt.want {
t.Errorf("want Side.IsSell() to be %v but got %v", tt.want, got)
}
})
}
}
+50
View File
@@ -0,0 +1,50 @@
package internal
import (
"context"
"encoding/json"
"time"
"github.com/shopspring/decimal"
)
// StateVersion is the schema version of the persisted State struct.
const StateVersion = "2"
// 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"`
Latest time.Time `json:"latest"`
Earliest time.Time `json:"earliest"`
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 json.RawMessage `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 JSON representation.
type RecordEncoder interface {
MarshalRecord(context.Context, Record) (json.RawMessage, error)
}
// RecordDecoder decodes broker-specific JSON back into a Record.
type RecordDecoder interface {
UnmarshalRecord(context.Context, json.RawMessage) (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
}
+50
View File
@@ -0,0 +1,50 @@
# Trading212
Turns Trading212 account statements (CSV) into the internal record model used
to build Anexo J reports. This file records how statement events are
interpreted, and why.
## Spin-off events
A spin-off distributes shares of one company to the shareholders of another.
Trading212 reports it as an `Action` of `Spin off`:
```
Spin off,2026-02-11 13:41:43+00:00,US9418481035,WAT,"Waters",EOF46451921527,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,
```
These rows are booked as **buys with a cost of zero**: quantity and timestamp
are kept, and every cost column (price, result, total, stamp duty reserve tax,
currency conversion fee, French transaction tax) is required to be zero —
anything non-zero, or a non-positive quantity, is logged and returned as an
error, stopping the run. A row that surprises the reader must be looked at by
a human, not silently booked.
### Why cost zero is fair
No money was paid for the new shares, and their value came out of the value
of the parent shares — on the distribution date the parent's price drops by
roughly the distributed value. The position's total basis is unchanged; it is
just partitioned over two lots: the spin-off lot at `0` and the parent lot at
its original, unreduced basis. Selling the spin-off lot books full proceeds as
gain; selling the parent lot shows the offsetting smaller gain or bigger loss.
Netted together, the result equals the true economic gain.
Example — buy 100 parent shares for 10,000; a spin-off delivers new shares
worth 2,000; later sell the new shares for 2,200 and the parent for 8,100:
| Lot | Basis (cost 0) | Sold for | Gain (cost 0) | Gain (pro-rata) |
| ------ | -------------: | -------: | ------------: | --------------: |
| New | 0 | 2,200 | 2,200 | 200 |
| Parent | 10,000 | 8,100 | -1,900 | 100 |
| Total | | | **300** | **300** |
Either partition of the same 10,000 basis yields the same net gain of 300
(true gain: 10,300 received − 10,000 paid) — as long as gains and losses are
assessed together, as they are in Anexo J within the same year. If the two
lots are sold in different years the per-year attribution differs (the total
does not). Trading212 statements don't report a pro-rata basis allocation, so
cost 0 also keeps the report reproducible from the statement alone.
> [!WARNING]
> Verify any outputs of this tool on your own or with a certified accountant.
+7
View File
@@ -0,0 +1,7 @@
package trading212
import (
"github.com/biter777/countries"
)
const Country = countries.Cyprus
+280 -47
View File
@@ -1,61 +1,83 @@
package trading212 package trading212
import ( import (
"context"
"encoding/csv" "encoding/csv"
"fmt" "fmt"
"io" "io"
"math/big" "log/slog"
"strings" "strings"
"sync"
"time" "time"
"git.naterciomoniz.net/applications/broker2anexoj/internal" "github.com/biter777/countries"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/ofigi"
"github.com/shopspring/decimal"
) )
type Record struct { type Record struct {
symbol string symbol string
side internal.Side
quantity *big.Float
price *big.Float
timestamp time.Time timestamp time.Time
fees *big.Float kind internal.Kind
taxes *big.Float quantity decimal.Decimal
price decimal.Decimal
fees decimal.Decimal
taxes decimal.Decimal
// natureGetter allows us to defer the operation of figuring out the nature to only when/if needed.
natureGetter func() internal.Nature
} }
func (r Record) Symbol() string { func (r Record) Symbol() string {
return r.symbol return r.symbol
} }
func (r Record) Side() internal.Side {
return r.side
}
func (r Record) Quantity() *big.Float {
return r.quantity
}
func (r Record) Price() *big.Float {
return r.price
}
func (r Record) Timestamp() time.Time { func (r Record) Timestamp() time.Time {
return r.timestamp return r.timestamp
} }
func (r Record) Fees() *big.Float { func (r Record) BrokerCountry() int64 {
return int64(Country)
}
func (r Record) AssetCountry() int64 {
return int64(countries.ByName(r.Symbol()[:2]).Info().Code)
}
func (r Record) Kind() internal.Kind {
return r.kind
}
func (r Record) Quantity() decimal.Decimal {
return r.quantity
}
func (r Record) Price() decimal.Decimal {
return r.price
}
func (r Record) Fees() decimal.Decimal {
return r.fees return r.fees
} }
func (r Record) Taxes() *big.Float { func (r Record) Taxes() decimal.Decimal {
return r.taxes return r.taxes
} }
func (r Record) Nature() internal.Nature {
return r.natureGetter()
}
type RecordReader struct { type RecordReader struct {
reader *csv.Reader reader *csv.Reader
figi *ofigi.Client
} }
func NewRecordReader(r io.Reader) *RecordReader { func NewRecordReader(r io.Reader, f *ofigi.Client) *RecordReader {
return &RecordReader{ return &RecordReader{
reader: csv.NewReader(r), reader: csv.NewReader(r),
figi: f,
} }
} }
@@ -64,28 +86,108 @@ const (
MarketSell = "market sell" MarketSell = "market sell"
LimitBuy = "limit buy" LimitBuy = "limit buy"
LimitSell = "limit sell" LimitSell = "limit sell"
StockSplitOpen = "stock split open"
StockSplitClose = "stock split close"
StokDistribution = "stock distribution"
SpinOff = "spin off"
) )
func (rr RecordReader) ReadRecord() (internal.Record, error) { // ReadRecord reads the next record from the statement, dispatching each
// action to the parser that validates exactly the columns that matter for
// it. Split open/close rows are paired up here because the pairing spans
// rows: the ratio is only known once the close row is seen.
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
var splitRec *splitRecord
for { for {
raw, err := rr.reader.Read() raw, err := rr.reader.Read()
if err != nil { if err != nil {
return Record{}, fmt.Errorf("read record: %w", err) return Record{}, fmt.Errorf("read record: %w", err)
} }
var side internal.Side action := strings.Trim(strings.ToLower(raw[0]), " ")
switch strings.ToLower(raw[0]) { if action == "action" { // header row
case MarketBuy, LimitBuy:
side = internal.SideBuy
case MarketSell, LimitSell:
side = internal.SideSell
case "action", "stock split open", "stock split close":
continue continue
}
switch action {
case MarketBuy, LimitBuy:
return rr.parseBuyRecord(ctx, raw)
case MarketSell, LimitSell:
return rr.parseSellRecord(ctx, raw)
case SpinOff:
return rr.parseSpinOff(ctx, raw)
case StockSplitOpen:
if splitRec != nil {
return nil, fmt.Errorf("split already open")
}
rec, err := rr.parseTradeRecord(ctx, raw)
if err != nil {
return nil, err
}
rec.kind = internal.KindSplit
splitRec = &splitRecord{Record: rec}
continue
case StockSplitClose:
if splitRec == nil {
return nil, fmt.Errorf("missing split open")
}
closeQty, err := parsePositiveDecimal(raw[6])
if err != nil {
return nil, fmt.Errorf("parse record quantity: %w", err)
}
splitRec.ratio = splitRec.Record.Quantity().Div(closeQty)
return splitRec, nil
case StokDistribution:
qant, err := parsePositiveDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
}
slog.Warn("Skipping 'stock distribution' event!",
slog.Any("ISIN", raw[2]),
slog.Any("Quantity", qant),
)
continue
default: default:
return Record{}, fmt.Errorf("parse record type: %s", raw[0]) return Record{}, fmt.Errorf("parse record type: %s", raw[0])
} }
}
}
qant, err := parseDecimal(raw[6]) // parseBuyRecord parses a market/limit buy row into a KindBuy Record.
func (rr RecordReader) parseBuyRecord(ctx context.Context, raw []string) (Record, error) {
rec, err := rr.parseTradeRecord(ctx, raw)
if err != nil {
return Record{}, err
}
rec.kind = internal.KindBuy
return rec, nil
}
// parseSellRecord parses a market/limit sell row into a KindSell Record.
func (rr RecordReader) parseSellRecord(ctx context.Context, raw []string) (Record, error) {
rec, err := rr.parseTradeRecord(ctx, raw)
if err != nil {
return Record{}, err
}
rec.kind = internal.KindSell
return rec, nil
}
// parseTradeRecord parses the columns shared by trade-shaped rows (quantity,
// price, timestamp, fees and taxes) into a Record with an unset Kind,
// leaving the kind decision to the caller. Split-open rows reuse it because
// their row shape is identical.
func (rr RecordReader) parseTradeRecord(ctx context.Context, raw []string) (Record, error) {
qant, err := parsePositiveDecimal(raw[6])
if err != nil { if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err) return Record{}, fmt.Errorf("parse record quantity: %w", err)
} }
@@ -95,52 +197,183 @@ func (rr RecordReader) ReadRecord() (internal.Record, error) {
return Record{}, fmt.Errorf("parse record price: %w", err) return Record{}, fmt.Errorf("parse record price: %w", err)
} }
ts, err := time.Parse(time.DateTime, raw[1]) ts, err := parseTimestamp(raw[1])
if err != nil { if err != nil {
return Record{}, fmt.Errorf("parse record timestamp: %w", err) return Record{}, fmt.Errorf("parse record timestamp: %w", err)
} }
convertionFee, err := parseOptinalDecimal(raw[16]) conversionFee, err := parseOptionalDecimal(raw[16])
if err != nil { if err != nil {
return Record{}, fmt.Errorf("parse record convertion fee: %w", err) return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
} }
stampDutyTax, err := parseOptinalDecimal(raw[14]) stampDutyTax, err := parseOptionalDecimal(raw[14])
if err != nil { if err != nil {
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err) return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
} }
frenchTxTax, err := parseOptinalDecimal(raw[18]) frenchTxTax, err := parseOptionalDecimal(raw[18])
if err != nil { if err != nil {
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err) return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
} }
return Record{ return Record{
symbol: raw[2], symbol: raw[2],
side: side,
quantity: qant, quantity: qant,
price: price, price: price,
fees: conversionFee,
taxes: stampDutyTax.Add(frenchTxTax),
timestamp: ts, timestamp: ts,
fees: convertionFee, natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
taxes: new(big.Float).Add(stampDutyTax, frenchTxTax),
}, nil }, nil
}
// parseSpinOff books a 'Spin off' statement row as a zero-cost buy. The
// zero-cost interpretation is only sound when no money moved with the
// distribution — see README.md in this package for the rationale — so every
// cost column is parsed as optional (an empty column means zero) and
// required to be zero: anything unexpected is logged and returned as an
// error so the run stops instead of silently booking a wrong lot.
func (rr RecordReader) parseSpinOff(ctx context.Context, raw []string) (Record, error) {
symbol := raw[2]
qant, err := parsePositiveDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
} }
ts, err := parseTimestamp(raw[1])
if err != nil {
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
}
// A slice (not a map) keeps validation and error reporting deterministic.
// result and total are only ever read here, for this row kind.
costs := []struct {
name string
column int
}{
{"price", 7},
{"result", 10},
{"total", 12},
{"stamp duty reserve tax", 14},
{"currency conversion fee", 16},
{"french transaction tax", 18},
}
for _, cost := range costs {
value, err := parseOptionalDecimal(raw[cost.column])
if err != nil {
return Record{}, fmt.Errorf("parse record %s: %w", cost.name, err)
}
if !value.IsZero() {
reason := fmt.Errorf("spin off with a non-zero %s (%s) for ISIN %s: refusing to book it as a zero-cost buy",
cost.name, value, symbol)
slog.Error("Unexpected 'spin off' row!",
slog.Any("err", reason),
slog.Any("ISIN", symbol),
)
return Record{}, reason
}
}
slog.Info("Booking 'spin off' event as a zero-cost buy!",
slog.Any("ISIN", symbol),
slog.Any("Quantity", qant),
)
return Record{
symbol: symbol,
kind: internal.KindBuy,
quantity: qant,
price: decimal.Zero,
fees: decimal.Zero,
taxes: decimal.Zero,
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
}, nil
}
func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature {
return sync.OnceValue(func() internal.Nature {
secType, err := of.SecurityTypeByISIN(ctx, isin)
if err != nil {
slog.Error("failed to get security type by ISIN", slog.Any("err", err), slog.String("isin", isin))
return internal.NatureUnknown
}
switch secType {
case "Common Stock", "ADR", "REIT":
return internal.NatureG01
case "ETP":
return internal.NatureG20
default:
slog.Error("got unsupported security type for ISIN", slog.String("isin", isin), slog.String("securityType", secType))
return internal.NatureUnknown
}
})
} }
// parseFloat attempts to parse a string using a standard precision and rounding mode. // parseFloat attempts to parse a string using a standard precision and rounding mode.
// Using this function helps avoid issues around converting values due to sligh parameter changes. // Using this function helps avoid issues around converting values due to minor parameter changes.
func parseDecimal(s string) (*big.Float, error) { func parseDecimal(s string) (decimal.Decimal, error) {
f, _, err := big.ParseFloat(s, 10, 128, big.ToZero) return decimal.NewFromString(s)
return f, err
} }
// parseOptinalDecimal behaves the same as parseDecimal but returns 0 when len(s) is 0 instead of // parseOptionalDecimal behaves the same as parseDecimal but returns 0 when len(s) is 0 instead of
// error. // error.
// Using this function helps avoid issues around converting values due to sligh parameter changes. // Using this function helps avoid issues around converting values due to minor parameter changes.
func parseOptinalDecimal(s string) (*big.Float, error) { func parseOptionalDecimal(s string) (decimal.Decimal, error) {
if len(s) == 0 { if len(s) == 0 {
return new(big.Float), nil return decimal.Decimal{}, nil
} }
return parseDecimal(s) return parseDecimal(s)
} }
// parsePositiveDecimal parses s as a decimal and requires it to be strictly
// positive: empty, malformed, zero and negative values are rejected. A lot
// (or a split-ratio denominator) is meaningless without a positive quantity.
func parsePositiveDecimal(s string) (decimal.Decimal, error) {
value, err := parseDecimal(s)
if err != nil {
return decimal.Decimal{}, err
}
if !value.IsPositive() {
return decimal.Decimal{}, fmt.Errorf("non-positive value %q", s)
}
return value, nil
}
// parseTimestamp parses a Trading212 timestamp string. Trading212 has shipped
// rows both without a timezone (treated here as UTC) and with an explicit
// numeric offset (e.g. "+00:00", "-05:00", "+01:00") or a "Z" UTC marker.
// The layouts are tried in order; the first match wins.
func parseTimestamp(s string) (time.Time, error) {
layouts := [3]string{
"2006-01-02 15:04:05-07:00", // numeric offset
time.DateTime, // "2006-01-02 15:04:05"
"2006-01-02 15:04:05Z", // explicit UTC marker
}
var (
lastErr error
ts time.Time
)
for _, layout := range layouts {
ts, lastErr = time.Parse(layout, s)
if lastErr == nil {
return ts, nil
}
}
return time.Time{}, lastErr
}
type splitRecord struct {
Record
ratio decimal.Decimal
}
func (sr splitRecord) Quantity() decimal.Decimal {
return sr.ratio
}
+373 -21
View File
@@ -2,12 +2,16 @@ package trading212
import ( import (
"bytes" "bytes"
"errors"
"fmt"
"io" "io"
"math/big" "net/http"
"testing" "testing"
"time" "time"
"git.naterciomoniz.net/applications/broker2anexoj/internal" "github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/ofigi"
"github.com/shopspring/decimal"
) )
func TestRecordReader_ReadRecord(t *testing.T) { func TestRecordReader_ReadRecord(t *testing.T) {
@@ -25,75 +29,183 @@ func TestRecordReader_ReadRecord(t *testing.T) {
}, },
{ {
name: "well-formed buy", name: "well-formed buy",
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29,SYM123456ABXY,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`), r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
want: Record{ want: Record{
symbol: "SYM123456ABXY", symbol: "XX1234567890",
side: internal.SideBuy, kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "2.4387014200"), quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.3690000000"), price: ShouldParseDecimal(t, "7.3690000000"),
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC), timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
fees: ShouldParseDecimal(t, "0.02"), fees: ShouldParseDecimal(t, "0.02"),
taxes: ShouldParseDecimal(t, "0.25"), taxes: ShouldParseDecimal(t, "0.25"),
natureGetter: func() internal.Nature { return internal.NatureG01 },
}, },
}, },
{ {
name: "well-formed sell", name: "well-formed sell",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"`), r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"`),
want: Record{ want: Record{
symbol: "IE000GA3D489", symbol: "XX1234567890",
side: internal.SideSell, kind: internal.KindSell,
quantity: ShouldParseDecimal(t, "2.4387014200"), quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.9999999999"), price: ShouldParseDecimal(t, "7.9999999999"),
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC), timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC),
fees: ShouldParseDecimal(t, "0.02"), fees: ShouldParseDecimal(t, "0.02"),
taxes: ShouldParseDecimal(t, "0.1"), taxes: ShouldParseDecimal(t, "0.1"),
natureGetter: func() internal.Nature { return internal.NatureG01 },
}, },
}, },
{ {
name: "malformed side", name: "malformed side",
r: bytes.NewBufferString(`Aljksdaf Balsjdkf,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), r: bytes.NewBufferString(`Aljksdaf Balsjdkf,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true, wantErr: true,
}, },
{ {
name: "empty side", name: "empty side",
r: bytes.NewBufferString(`,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), r: bytes.NewBufferString(`,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true, wantErr: true,
}, },
{ {
name: "malformed qantity", name: "malformed qantity",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true, wantErr: true,
}, },
{ {
name: "empty qantity", name: "empty qantity",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true, wantErr: true,
}, },
{ {
name: "malformed price", name: "malformed price",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,0b101010,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,0b101010,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true, wantErr: true,
}, },
{ {
name: "empty price", name: "empty price",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "malformed fees",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,BAD,"EUR",0.1,"EUR"`),
wantErr: true,
},
{
name: "malformed taxes",
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",BAD,"EUR"`),
wantErr: true, wantErr: true,
}, },
{ {
name: "malformed timestamp", name: "malformed timestamp",
r: bytes.NewBufferString(`Market sell,2006-01-02T15:04:05Z07:00,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), r: bytes.NewBufferString(`Market sell,2006-01-02T15:04:05Z07:00,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true, wantErr: true,
}, },
{ {
name: "empty timestamp", name: "empty timestamp",
r: bytes.NewBufferString(`Market sell,,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`), r: bytes.NewBufferString(`Market sell,,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
wantErr: true, wantErr: true,
}, },
{
name: "well-formed buy with new offset format +00:00",
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
want: Record{
symbol: "XX1234567890",
kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.3690000000"),
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.FixedZone("UTC", 0)),
fees: ShouldParseDecimal(t, "0.02"),
taxes: ShouldParseDecimal(t, "0.25"),
natureGetter: func() internal.Nature { return internal.NatureG01 },
},
},
{
name: "well-formed buy with new offset format +01:00",
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29+01:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
want: Record{
symbol: "XX1234567890",
kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.3690000000"),
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.FixedZone("+01:00", 1*60*60)),
fees: ShouldParseDecimal(t, "0.02"),
taxes: ShouldParseDecimal(t, "0.25"),
natureGetter: func() internal.Nature { return internal.NatureG01 },
},
},
{
name: "well-formed buy with garbage timestamp returns error",
r: bytes.NewBufferString(`Market buy,not-a-timestamp,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
wantErr: true,
},
{
name: "spin off booked as zero-cost buy",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,`),
want: Record{
symbol: "XX1234567890",
kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "0.0406450000"),
price: decimal.Zero,
fees: decimal.Zero,
taxes: decimal.Zero,
timestamp: time.Date(2026, 2, 11, 13, 41, 43, 0, time.FixedZone("UTC", 0)),
natureGetter: func() internal.Nature { return internal.NatureG01 },
},
},
{
name: "spin off with a non-zero price is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,45.2300000000,USD,,,,0.00,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero result is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,1.17995999,1.25,"EUR",0.00,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero total is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,1.25,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero stamp duty reserve tax is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",0.25,"EUR",,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero currency conversion fee is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "spin off with a non-zero french transaction tax is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,0.1,"EUR"`),
wantErr: true,
},
{
name: "spin off with a non-positive quantity is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0E-10,0E-10,USD,,,,0.00,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with an empty price column is booked as a zero-cost buy",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,,USD,,,,0.00,"EUR",,,,,,`),
want: Record{
symbol: "XX1234567890",
kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "0.0406450000"),
price: decimal.Zero,
fees: decimal.Zero,
taxes: decimal.Zero,
timestamp: time.Date(2026, 2, 11, 13, 41, 43, 0, time.FixedZone("UTC", 0)),
natureGetter: func() internal.Nature { return internal.NatureG01 },
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
rr := NewRecordReader(tt.r) rr := NewRecordReader(tt.r, NewFigiClientSecurityTypeStub(t, "Common Stock"))
got, gotErr := rr.ReadRecord() got, gotErr := rr.ReadRecord(t.Context())
if gotErr != nil { if gotErr != nil {
if !tt.wantErr { if !tt.wantErr {
t.Fatalf("ReadRecord() failed: %v", gotErr) t.Fatalf("ReadRecord() failed: %v", gotErr)
@@ -109,8 +221,8 @@ func TestRecordReader_ReadRecord(t *testing.T) {
t.Fatalf("want symbol %v but got %v", tt.want.symbol, got.Symbol()) t.Fatalf("want symbol %v but got %v", tt.want.symbol, got.Symbol())
} }
if got.Side() != tt.want.side { if got.Kind() != tt.want.kind {
t.Fatalf("want side %v but got %v", tt.want.side, got.Side()) t.Fatalf("want side %v but got %v", tt.want.kind, got.Kind())
} }
if got.Price().Cmp(tt.want.price) != 0 { if got.Price().Cmp(tt.want.price) != 0 {
@@ -132,11 +244,188 @@ func TestRecordReader_ReadRecord(t *testing.T) {
if got.Taxes().Cmp(tt.want.taxes) != 0 { if got.Taxes().Cmp(tt.want.taxes) != 0 {
t.Fatalf("want taxes %v but got %v", tt.want.taxes, got.Taxes()) t.Fatalf("want taxes %v but got %v", tt.want.taxes, got.Taxes())
} }
if tt.want.natureGetter != nil && tt.want.Nature() != got.Nature() {
t.Fatalf("want nature %v but got %v", tt.want.Nature(), got.Nature())
}
}) })
} }
} }
func ShouldParseDecimal(t testing.TB, sf string) *big.Float { func TestRecordReader_ReadRecord_Split(t *testing.T) {
// open row has the NEW (post-split) position: more shares at lower price
// close row has the OLD (pre-split) position: fewer shares at higher price
// ratio = openQty / closeQty = 0.5 / 0.1 = 5 (a 5:1 split)
splitOpen := `Stock split open,2025-06-03 05:34:16,XX1234567890,ABXY,"Aspargus Broccoli",EOF111111111,0.5000000000,20.0000000000,EUR,1.00000000,,,10.00,"EUR",,,,,,`
splitClose := `Stock split close,2025-06-03 05:34:16,XX1234567890,ABXY,"Aspargus Broccoli",EOF222222222,0.1000000000,100.0000000000,EUR,1.00000000,0.00,"EUR",10.00,"EUR",,,,,,`
t.Run("well-formed split pair returns split record with correct ratio", func(t *testing.T) {
rr := NewRecordReader(
bytes.NewBufferString(splitOpen+"\n"+splitClose),
NewFigiClientSecurityTypeStub(t, "Common Stock"),
)
got, err := rr.ReadRecord(t.Context())
if err != nil {
t.Fatalf("ReadRecord() failed: %v", err)
}
if got.Kind() != internal.KindSplit {
t.Errorf("want kind %v but got %v", internal.KindSplit, got.Kind())
}
if got.Symbol() != "XX1234567890" {
t.Errorf("want symbol NO0013536151 but got %v", got.Symbol())
}
wantTimestamp := time.Date(2025, 6, 3, 5, 34, 16, 0, time.UTC)
if !got.Timestamp().Equal(wantTimestamp) {
t.Errorf("want timestamp %v but got %v", wantTimestamp, got.Timestamp())
}
// ratio = openQty / closeQty = 0.1245045 / 0.0249009 ≈ 5
openQty := ShouldParseDecimal(t, "0.1245045000")
closeQty := ShouldParseDecimal(t, "0.0249009000")
wantRatio := openQty.Div(closeQty)
if !got.Quantity().Equal(wantRatio) {
t.Errorf("want ratio %v but got %v", wantRatio, got.Quantity())
}
})
t.Run("close without prior open errors", func(t *testing.T) {
rr := NewRecordReader(
bytes.NewBufferString(splitClose),
NewFigiClientSecurityTypeStub(t, "Common Stock"),
)
_, err := rr.ReadRecord(t.Context())
if err == nil {
t.Fatal("expected error but got none")
}
})
t.Run("two opens without close errors", func(t *testing.T) {
rr := NewRecordReader(
bytes.NewBufferString(splitOpen+"\n"+splitOpen),
NewFigiClientSecurityTypeStub(t, "Common Stock"),
)
_, err := rr.ReadRecord(t.Context())
if err == nil {
t.Fatal("expected error but got none")
}
})
}
func TestParseTimestamp(t *testing.T) {
utc := time.FixedZone("UTC", 0)
plusOne := time.FixedZone("+01:00", 1*60*60)
minusFive := time.FixedZone("-05:00", -5*60*60)
tests := []struct {
name string
input string
want time.Time
wantErr bool
}{
{
name: "legacy format without offset parses as UTC",
input: "2025-05-22 13:15:52",
want: time.Date(2025, 5, 22, 13, 15, 52, 0, time.UTC),
},
{
name: "new format with +00:00 offset",
input: "2025-05-22 13:15:52+00:00",
want: time.Date(2025, 5, 22, 13, 15, 52, 0, utc),
},
{
name: "new format with +01:00 offset",
input: "2025-05-22 13:15:52+01:00",
want: time.Date(2025, 5, 22, 13, 15, 52, 0, plusOne),
},
{
name: "new format with -05:00 offset",
input: "2025-05-22 13:15:52-05:00",
want: time.Date(2025, 5, 22, 13, 15, 52, 0, minusFive),
},
{
name: "explicit Z UTC marker",
input: "2025-05-22 13:15:52Z",
want: time.Date(2025, 5, 22, 13, 15, 52, 0, time.UTC),
},
{
name: "garbage input returns error",
input: "not-a-timestamp",
wantErr: true,
},
{
name: "RFC3339 with T separator is rejected",
input: "2025-05-22T13:15:52Z",
wantErr: true,
},
{
name: "empty string returns error",
input: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTimestamp(tt.input)
if err != nil {
if !tt.wantErr {
t.Fatalf("parseTimestamp(%q) returned error: %v", tt.input, err)
}
return
}
if tt.wantErr {
t.Fatalf("parseTimestamp(%q) expected an error", tt.input)
}
if !got.Equal(tt.want) {
t.Fatalf("parseTimestamp(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func Test_figiNatureGetter(t *testing.T) {
tests := []struct {
name string // description of this test case
of *ofigi.Client
want internal.Nature
}{
{
name: "Common Stock translates to G01",
of: NewFigiClientSecurityTypeStub(t, "Common Stock"),
want: internal.NatureG01,
},
{
name: "ETP translates to G20",
of: NewFigiClientSecurityTypeStub(t, "ETP"),
want: internal.NatureG20,
},
{
name: "Other translates to Unknown",
of: NewFigiClientSecurityTypeStub(t, "Other"),
want: internal.NatureUnknown,
},
{
name: "Request fails",
of: NewFigiClientErrorStub(t, fmt.Errorf("boom")),
want: internal.NatureUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
getter := figiNatureGetter(t.Context(), tt.of, "IR1234567890")
got := getter()
if tt.want != got {
t.Errorf("want %v but got %v", tt.want, got)
}
})
}
}
func ShouldParseDecimal(t testing.TB, sf string) decimal.Decimal {
t.Helper() t.Helper()
bf, err := parseDecimal(sf) bf, err := parseDecimal(sf)
@@ -145,3 +434,66 @@ func ShouldParseDecimal(t testing.TB, sf string) *big.Float {
} }
return bf return bf
} }
func TestRecordReader_ReadRecord_SkipsCorporateActions(t *testing.T) {
tests := []struct {
name string
row string
}{
{
name: "stock distribution",
row: `Stock distribution,2025-08-04 11:45:30,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr := NewRecordReader(
bytes.NewBufferString(tt.row),
NewFigiClientSecurityTypeStub(t, "Common Stock"),
)
_, err := rr.ReadRecord(t.Context())
if !errors.Is(err, io.EOF) {
t.Fatalf("want EOF after skipping %s, got: %v", tt.name, err)
}
})
}
}
type RoundTripFunc func(req *http.Request) (*http.Response, error)
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *ofigi.Client {
t.Helper()
c := &http.Client{
Timeout: time.Second,
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`[{"data":[{"securityType":%q}]}]`, securityType))),
Request: req,
}, nil
}),
}
return ofigi.NewOpenFIGI(c, "")
}
func NewFigiClientErrorStub(t testing.TB, err error) *ofigi.Client {
t.Helper()
c := &http.Client{
Timeout: time.Second,
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, err
}),
}
return ofigi.NewOpenFIGI(c, "")
}
+88
View File
@@ -0,0 +1,88 @@
package trading212
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/ofigi"
"github.com/shopspring/decimal"
)
// recordState is the on-disk representation of a trading212.Record.
// Nature is intentionally omitted because it is resolved lazily via the
// OpenFIGI client on demand.
type recordState struct {
Symbol string `json:"symbol"`
Timestamp time.Time `json:"timestamp"`
Kind internal.Kind `json:"kind"`
Quantity decimal.Decimal `json:"quantity"`
Price decimal.Decimal `json:"price"`
Fees decimal.Decimal `json:"fees"`
Taxes decimal.Decimal `json:"taxes"`
}
// RecordSerializer encodes and decodes trading212.Record values for the
// internal persistence layer. On decode it re-wires the lazy natureGetter to
// the supplied OpenFIGI client so a restored Record resolves Nature() via a
// fresh API call when needed.
type RecordSerializer struct {
figi *ofigi.Client
}
// NewRecordSerializer returns a RecordSerializer that uses figi to resolve
// Record.Nature() on load.
func NewRecordSerializer(figi *ofigi.Client) *RecordSerializer {
return &RecordSerializer{figi: figi}
}
// MarshalRecord encodes the given internal.Record as JSON. It returns an
// error if the record is not a trading212.Record (or a *trading212.Record).
func (s *RecordSerializer) MarshalRecord(_ context.Context, r internal.Record) (json.RawMessage, 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 json.RawMessage) (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
@@ -0,0 +1,151 @@
package trading212
import (
"bytes"
"io"
"net/http"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/ofigi"
"github.com/shopspring/decimal"
)
func TestRecordSerializer_RoundTrip(t *testing.T) {
want := Record{
symbol: "XX1234567890",
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.3690000000"),
fees: ShouldParseDecimal(t, "0.02"),
taxes: ShouldParseDecimal(t, "0.25"),
natureGetter: func() internal.Nature { return internal.NatureG01 },
}
s := NewRecordSerializer(NewFigiClientSecurityTypeStub(t, "Common Stock"))
data, err := s.MarshalRecord(t.Context(), want)
if err != nil {
t.Fatalf("MarshalRecord: %v", err)
}
got, err := s.UnmarshalRecord(t.Context(), data)
if err != nil {
t.Fatalf("UnmarshalRecord: %v", err)
}
if got.Symbol() != want.Symbol() {
t.Errorf("Symbol: want %q but got %q", want.Symbol(), got.Symbol())
}
if got.Kind() != want.Kind() {
t.Errorf("Kind: want %v but got %v", want.Kind(), got.Kind())
}
if !got.Price().Equal(want.Price()) {
t.Errorf("Price: want %v but got %v", want.Price(), got.Price())
}
if !got.Quantity().Equal(want.Quantity()) {
t.Errorf("Quantity: want %v but got %v", want.Quantity(), got.Quantity())
}
if !got.Fees().Equal(want.Fees()) {
t.Errorf("Fees: want %v but got %v", want.Fees(), got.Fees())
}
if !got.Taxes().Equal(want.Taxes()) {
t.Errorf("Taxes: want %v but got %v", want.Taxes(), got.Taxes())
}
if !got.Timestamp().Equal(want.Timestamp()) {
t.Errorf("Timestamp: want %v but got %v", want.Timestamp(), got.Timestamp())
}
}
func TestRecordSerializer_UnmarshalRecord_NatureTriggersOpenFIGI(t *testing.T) {
var calls int
client := &http.Client{
Timeout: time.Second,
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
Request: req,
}, nil
}),
}
s := NewRecordSerializer(ofigi.NewOpenFIGI(client, ""))
original := Record{
symbol: "XX1234567890",
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "2.4387014200"),
price: ShouldParseDecimal(t, "7.3690000000"),
fees: ShouldParseDecimal(t, "0.02"),
taxes: ShouldParseDecimal(t, "0.25"),
// Pre-populated so MarshalRecord doesn't accidentally trigger an
// OpenFIGI call when encoding the original record.
natureGetter: func() internal.Nature { return internal.NatureG01 },
}
data, err := s.MarshalRecord(t.Context(), original)
if err != nil {
t.Fatalf("MarshalRecord: %v", err)
}
if calls != 0 {
t.Fatalf("OpenFIGI called during MarshalRecord: %d", calls)
}
got, err := s.UnmarshalRecord(t.Context(), data)
if err != nil {
t.Fatalf("UnmarshalRecord: %v", err)
}
// Nature must not have been resolved yet — natureGetter is lazy.
if calls != 0 {
t.Fatalf("OpenFIGI called before Nature(): %d", calls)
}
if nature := got.Nature(); nature != internal.NatureG01 {
t.Errorf("Nature: want %v but got %v", internal.NatureG01, nature)
}
if calls != 1 {
t.Errorf("OpenFIGI request count: want 1 but got %d", calls)
}
// Subsequent Nature() calls should not re-trigger the request (the
// underlying sync.OnceValue caches the result on the client too).
if nature := got.Nature(); nature != internal.NatureG01 {
t.Errorf("Nature (cached): want %v but got %v", internal.NatureG01, nature)
}
if calls != 1 {
t.Errorf("OpenFIGI request count after re-read: want 1 but got %d", calls)
}
}
func TestRecordSerializer_MarshalRecord_WrongType(t *testing.T) {
s := NewRecordSerializer(NewFigiClientSecurityTypeStub(t, "Common Stock"))
_, err := s.MarshalRecord(t.Context(), stubRecord{})
if err == nil {
t.Fatal("want error but got nil")
}
}
// stubRecord is a non-trading212 implementation of internal.Record used to
// verify that MarshalRecord rejects unrelated record types.
type stubRecord struct{}
func (stubRecord) Symbol() string { return "STUB" }
func (stubRecord) Nature() internal.Nature { return internal.NatureUnknown }
func (stubRecord) BrokerCountry() int64 { return 0 }
func (stubRecord) AssetCountry() int64 { return 0 }
func (stubRecord) Kind() internal.Kind { return internal.KindUnknown }
func (stubRecord) Price() decimal.Decimal { return decimal.Zero }
func (stubRecord) Quantity() decimal.Decimal { return decimal.Zero }
func (stubRecord) Timestamp() time.Time { return time.Time{} }
func (stubRecord) Fees() decimal.Decimal { return decimal.Zero }
func (stubRecord) Taxes() decimal.Decimal { return decimal.Zero }
+59
View File
@@ -0,0 +1,59 @@
// Package csv renders an internal.AggregatorWriter as the CSV schema
// expected by Modelo 3 do Anexo J.
package csv
import (
"encoding/csv"
"fmt"
"io"
"github.com/nmoniz/any2anexoj/internal"
)
type Writer struct {
w *csv.Writer
}
func NewWriter(w io.Writer) *Writer {
return &Writer{w: csv.NewWriter(w)}
}
func (cw *Writer) Render(aw *internal.AggregatorWriter) error {
err := cw.w.Write([]string{
"source_country", "code",
"realization_year", "realization_month", "realization_day", "realization_value",
"acquisition_year", "acquisition_month", "acquisition_day", "acquisition_value",
"expenses", "foreign_tax_paid", "counter_country",
})
if err != nil {
return fmt.Errorf("write csv header: %w", err)
}
for ri := range aw.Iter() {
err := cw.w.Write(reportItemToRow(ri))
if err != nil {
return fmt.Errorf("write csv row: %w", err)
}
}
cw.w.Flush()
return cw.w.Error()
}
func reportItemToRow(ri internal.ReportItem) []string {
return []string{
fmt.Sprintf("%d", ri.AssetCountry),
string(ri.Nature),
fmt.Sprintf("%d", ri.SellTimestamp.Year()),
fmt.Sprintf("%d", int(ri.SellTimestamp.Month())),
fmt.Sprintf("%d", ri.SellTimestamp.Day()),
ri.SellValue.StringFixed(2),
fmt.Sprintf("%d", ri.BuyTimestamp.Year()),
fmt.Sprintf("%d", int(ri.BuyTimestamp.Month())),
fmt.Sprintf("%d", ri.BuyTimestamp.Day()),
ri.BuyValue.StringFixed(2),
ri.Fees.StringFixed(2),
ri.Taxes.StringFixed(2),
fmt.Sprintf("%d", ri.BrokerCountry),
}
}
+78
View File
@@ -0,0 +1,78 @@
package csv_test
import (
"bytes"
"strings"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/writing/csv"
"github.com/shopspring/decimal"
)
func TestWriter_Render(t *testing.T) {
aw := internal.NewAggregatorWriter()
ctx := t.Context()
items := []internal.ReportItem{
{
Symbol: "AAPL",
Nature: internal.NatureG01,
BrokerCountry: 826, // United Kingdom
AssetCountry: 840, // United States
BuyValue: decimal.NewFromFloat(100.50),
BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
SellValue: decimal.NewFromFloat(150.75),
SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC),
Fees: decimal.NewFromFloat(2.50),
Taxes: decimal.NewFromFloat(5.00),
},
{
Symbol: "VWRL",
Nature: internal.NatureG20,
BrokerCountry: 196, // Portugal equivalent demo
AssetCountry: 372, // Ireland
BuyValue: decimal.NewFromFloat(200.00),
BuyTimestamp: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
SellValue: decimal.NewFromFloat(250.25),
SellTimestamp: time.Date(2024, 7, 10, 0, 0, 0, 0, time.UTC),
Fees: decimal.NewFromFloat(1.00),
Taxes: decimal.NewFromFloat(0.00),
},
}
for _, ri := range items {
if err := aw.Write(ctx, ri); err != nil {
t.Fatalf("write report item: %v", err)
}
}
var buf bytes.Buffer
if err := csv.NewWriter(&buf).Render(aw); err != nil {
t.Fatalf("render: %v", err)
}
out := buf.String()
wantHeader := "source_country,code,realization_year,realization_month,realization_day,realization_value,acquisition_year,acquisition_month,acquisition_day,acquisition_value,expenses,foreign_tax_paid,counter_country"
if !strings.HasPrefix(out, wantHeader+"\n") {
t.Errorf("expected header row %q, got output:\n%s", wantHeader, out)
}
wantRow1 := "840,G01,2023,6,20,150.75,2023,1,15,100.50,2.50,5.00,826"
if !strings.Contains(out, wantRow1+"\n") {
t.Errorf("expected row 1 %q in output:\n%s", wantRow1, out)
}
wantRow2 := "372,G20,2024,7,10,250.25,2024,2,1,200.00,1.00,0.00,196"
if !strings.Contains(out, wantRow2+"\n") {
t.Errorf("expected row 2 %q in output:\n%s", wantRow2, out)
}
// Make sure the header only appears once and we have exactly 1 header + 2 data rows.
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if got, want := len(lines), 3; got != want {
t.Errorf("expected %d lines (1 header + 2 rows), got %d:\n%s", want, got, out)
}
}
+6
View File
@@ -0,0 +1,6 @@
@charset "UTF-8";/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
/*# sourceMappingURL=bootstrap.min.css.map */
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<style>{{.CSS}}</style>
</head>
<body>
<main class="container-fluid py-4">
<h1>{{.Title}}</h1>
<div class="table-responsive">
{{.TableFragment}}
</div>
<p class="text-muted small">Generated at {{.GeneratedAt}}</p>
</main>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
package html
import (
"fmt"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
)
// htmlCSSClass is the CSS class list put on the rendered report table.
const htmlCSSClass = "table table-striped table-hover table-bordered"
// widthIndex is the weight of the auto-index (row number) column; the other
// column weights come from tabledef's width budgets.
const widthIndex = 6
// htmlColumnWeights lists one weight per rendered column, starting with the
// auto-index column, mirroring the column order of tabledef.Build so the
// HTML table keeps columns in the same strict proportion to each other as
// the terminal layout.
var htmlColumnWeights = []int{
widthIndex,
tabledef.WidthCountry, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthEuros,
tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthEuros,
tabledef.WidthEuros, tabledef.WidthEuros, tabledef.WidthCountry,
}
// renderTableFragment renders aw as the Bootstrap-styled HTML <table>
// fragment embedded in the document. The table gets a fixed layout with
// strictly proportional column widths, so the browser — not this package —
// decides when a header or cell wraps: on wide screens everything fits on
// one line, on narrow ones it wraps automatically.
func (hw *Writer) renderTableFragment(aw *internal.AggregatorWriter) string {
t := tabledef.Build(hw.translator, aw)
t.Style().HTML = table.HTMLOptions{
CSSClass: htmlCSSClass,
EscapeText: true,
Newline: "<br/>",
EmptyColumn: "&nbsp;",
}
html := t.RenderHTML()
// go-pretty cannot emit table-level attributes or a <colgroup>, so splice
// them into its output: the fixed layout makes the colgroup's relative
// widths authoritative instead of content-driven column sizing.
openTag := `<table class="` + htmlCSSClass + `">`
fixedTag := `<table class="` + htmlCSSClass + `" style="table-layout: fixed">` + "\n" + colgroupHTML()
return strings.Replace(html, openTag, fixedTag, 1)
}
// colgroupHTML returns a <colgroup> with one <col> per rendered column, each
// sized strictly in proportion to its weight. With the fixed table layout
// applied in renderTableFragment these widths are authoritative: wide
// viewports fit headers on a single line, narrow ones wrap automatically.
func colgroupHTML() string {
var total int
for _, w := range htmlColumnWeights {
total += w
}
var sb strings.Builder
sb.WriteString("<colgroup>")
for _, w := range htmlColumnWeights {
fmt.Fprintf(&sb, `<col style="width: %.2f%%">`, float64(w)/float64(total)*100)
}
sb.WriteString("</colgroup>")
return sb.String()
}
+60
View File
@@ -0,0 +1,60 @@
package html_test
import (
"bytes"
"strings"
"testing"
"github.com/nmoniz/any2anexoj/internal/writing"
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
)
// TestWriter_RenderTableFragment renders a Portuguese document and asserts
// the embedded report table fragment carries the shared structure: the
// translated headers, the totals footer, and the strictly proportional
// colgroup that keeps HTML columns in the same relation to each other as
// the terminal layout.
func TestWriter_RenderTableFragment(t *testing.T) {
aw := buildTestAggregator(t)
localizer, err := writing.NewLocalizer("pt")
if err != nil {
t.Fatalf("failed to create localizer: %v", err)
}
var buf bytes.Buffer
hw := htmlwriter.NewWriter(&buf, localizer, "pt")
if err := hw.Render(aw); err != nil {
t.Fatalf("Render returned an error: %v", err)
}
got := buf.String()
wantSubstrings := []string{
`<table class="table table-striped table-hover table-bordered" style="table-layout: fixed">`,
`<colgroup>`,
`</colgroup>`,
`<col style="width: 3.03%">`,
`<col style="width: 12.12%">`,
`</table>`,
`<thead>`,
`</thead>`,
`<tbody>`,
`</tbody>`,
`<tfoot>`,
`</tfoot>`,
"Realização",
"Aquisição",
"Soma",
}
for _, s := range wantSubstrings {
if !strings.Contains(got, s) {
t.Errorf("rendered document missing expected substring %q\nGot:\n%s", s, got)
}
}
// The fragment must be embedded raw, not HTML-escaped.
if strings.Contains(got, "&lt;table") {
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
}
}
+85
View File
@@ -0,0 +1,85 @@
// Package html renders an internal.AggregatorWriter as a complete,
// standalone HTML document: the report table fragment (built from the
// shared table definition in internal/writing/internal/tabledef, so it can
// never drift from the terminal layout) embedded in a Bootstrap-styled page
// with a translated title and generation timestamp. The official Bootstrap
// 5.3.3 stylesheet is inlined via go:embed, so the document renders fully
// offline.
package html
import (
_ "embed"
"fmt"
"html/template"
"io"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
)
// bootstrapMinCSS is the official Bootstrap 5.3.3 minified CSS distribution
// file, embedded so every rendered document renders fully offline. After any
// version bump, re-verify provenance with:
//
// openssl dgst -sha384 -binary bootstrap.min.css | base64
//
// It must print the official SRI hash published at
// https://getbootstrap.com/docs/5.3/getting-started/download/
// (sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH).
//
//go:embed bootstrap.min.css
var bootstrapMinCSS string
// Writer writes a complete HTML document for an AggregatorWriter.
type Writer struct {
output io.Writer
translator tabledef.Translator
lang string
}
// NewWriter builds a Writer that writes a complete HTML document to w. The
// report table is rendered from the shared table definition via tr, and the
// document title is looked up from the "report_title" translation via tr,
// so callers never pass a pre-baked title.
func NewWriter(w io.Writer, tr tabledef.Translator, lang string) *Writer {
return &Writer{
output: w,
translator: tr,
lang: lang,
}
}
//go:embed document.tmpl
var documentTemplateBody string
// documentTemplate renders the full HTML page from document.tmpl. The table
// fragment is typed as template.HTML so the template engine emits it
// verbatim without escaping the angle brackets in the <table> markup.
var documentTemplate = template.Must(template.New("html").Parse(documentTemplateBody))
// Render writes a complete standalone HTML document for aw to the configured
// output. The same Writer may be reused; each call rebuilds the table
// fragment from aw so the rendered <tr> count reflects aw's current items and
// no rows are duplicated across invocations.
func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
fragment := hw.renderTableFragment(aw)
title := hw.translator.Translate("report_title", 1, nil)
data := struct {
Lang string
Title string
CSS template.CSS
TableFragment template.HTML
GeneratedAt string
}{
Lang: hw.lang,
Title: title,
CSS: template.CSS(bootstrapMinCSS),
TableFragment: template.HTML(fragment),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := documentTemplate.Execute(hw.output, data); err != nil {
return fmt.Errorf("render html document: %w", err)
}
return nil
}
+188
View File
@@ -0,0 +1,188 @@
package html_test
import (
"bytes"
"strings"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/writing"
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
"github.com/shopspring/decimal"
)
// buildTestAggregator returns an AggregatorWriter seeded with two
// deterministic ReportItems so every test can assert on a stable document
// shape. Mirrors the fixture pattern used in
// internal/writing/table/printer_test.go.
func buildTestAggregator(t *testing.T) *internal.AggregatorWriter {
t.Helper()
aw := internal.NewAggregatorWriter()
ctx := t.Context()
err := aw.Write(ctx, internal.ReportItem{
Symbol: "AAPL",
Nature: internal.NatureG01,
BrokerCountry: 826,
AssetCountry: 840,
BuyValue: decimal.NewFromFloat(100.50),
BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
SellValue: decimal.NewFromFloat(150.75),
SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC),
Fees: decimal.NewFromFloat(2.50),
Taxes: decimal.NewFromFloat(5.00),
})
if err != nil {
t.Fatalf("failed to write first report item: %v", err)
}
err = aw.Write(ctx, internal.ReportItem{
Symbol: "GOOGL",
Nature: internal.NatureG20,
BrokerCountry: 826,
AssetCountry: 840,
BuyValue: decimal.NewFromFloat(200.00),
BuyTimestamp: time.Date(2023, 3, 10, 0, 0, 0, 0, time.UTC),
SellValue: decimal.NewFromFloat(225.50),
SellTimestamp: time.Date(2023, 9, 5, 0, 0, 0, 0, time.UTC),
Fees: decimal.NewFromFloat(3.00),
Taxes: decimal.NewFromFloat(7.50),
})
if err != nil {
t.Fatalf("failed to write second report item: %v", err)
}
return aw
}
func TestWriter_Render(t *testing.T) {
loc, err := writing.NewLocalizer("en")
if err != nil {
t.Fatalf("create localizer: %v", err)
}
var buf bytes.Buffer
hw := htmlwriter.NewWriter(&buf, loc, "en")
if err := hw.Render(buildTestAggregator(t)); err != nil {
t.Fatalf("Render returned an error: %v", err)
}
got := buf.String()
wantSubstrings := []string{
"<!DOCTYPE html>",
`<html lang="en">`,
`<meta charset="utf-8">`,
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
`<title>Tax report</title>`,
`<style>`,
`Bootstrap v5.3.3`,
`<main class="container-fluid py-4">`,
`<h1>Tax report</h1>`,
`<div class="table-responsive">`,
`<table class="table table-striped table-hover table-bordered" style="table-layout: fixed">`,
`<colgroup>`,
`</table>`,
`<tfoot>`,
`</tfoot>`,
`</html>`,
`<p class="text-muted small">Generated at `,
}
for _, s := range wantSubstrings {
if !strings.Contains(got, s) {
t.Errorf("rendered HTML missing expected substring %q\nGot:\n%s", s, got)
}
}
// The table fragment must be embedded as raw HTML, not escaped.
if strings.Contains(got, "&lt;table") {
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
}
if !strings.Contains(got, "<table") {
t.Errorf("expected raw <table> tag in output\nGot:\n%s", got)
}
// The document must be fully self-contained: no external stylesheet
// <link> and no CDN reference anywhere.
if strings.Contains(got, "<link") {
t.Errorf("expected no external stylesheet <link>; document must be self-contained\nGot:\n%s", got)
}
if strings.Contains(got, "cdn.jsdelivr.net") {
t.Errorf("expected no CDN references; document must be self-contained\nGot:\n%s", got)
}
// Generated timestamp must be valid RFC3339 UTC.
startIdx := strings.Index(got, "Generated at ")
if startIdx == -1 {
t.Fatalf("missing 'Generated at' prefix in output")
}
timestampLine := got[startIdx:]
endIdx := strings.Index(timestampLine, "</p>")
if endIdx == -1 {
t.Fatalf("missing </p> closing tag after 'Generated at' line")
}
timestampStr := strings.TrimPrefix(strings.SplitN(timestampLine, "</p>", 2)[0], "Generated at ")
if _, err := time.Parse(time.RFC3339, timestampStr); err != nil {
t.Errorf("generation timestamp is not valid RFC3339: %q (err: %v)", timestampStr, err)
}
}
// TestWriter_NoRowDuplication verifies that calling Render twice on the
// same writer produces documents with the same number of <tr> rows, just
// like table.Printer's no-duplication guarantee.
func TestWriter_NoRowDuplication(t *testing.T) {
loc, err := writing.NewLocalizer("en")
if err != nil {
t.Fatalf("create localizer: %v", err)
}
var buf bytes.Buffer
hw := htmlwriter.NewWriter(&buf, loc, "en")
aw := buildTestAggregator(t)
if err := hw.Render(aw); err != nil {
t.Fatalf("first Render returned an error: %v", err)
}
first := buf.String()
buf.Reset()
if err := hw.Render(aw); err != nil {
t.Fatalf("second Render returned an error: %v", err)
}
second := buf.String()
firstRows := strings.Count(first, "<tr")
secondRows := strings.Count(second, "<tr")
if firstRows == 0 {
t.Fatalf("expected at least one <tr> row in the first render; got:\n%s", first)
}
if firstRows != secondRows {
t.Errorf("expected second Render to produce the same number of <tr> rows as the first (no duplication)\nfirst rows=%d, second rows=%d\nFirst:\n%s\nSecond:\n%s",
firstRows, secondRows, first, second)
}
}
func TestWriter_LangAttribute(t *testing.T) {
loc, err := writing.NewLocalizer("pt")
if err != nil {
t.Fatalf("create localizer: %v", err)
}
var buf bytes.Buffer
hw := htmlwriter.NewWriter(&buf, loc, "pt")
if err := hw.Render(buildTestAggregator(t)); err != nil {
t.Fatalf("Render returned an error: %v", err)
}
got := buf.String()
if !strings.Contains(got, `<html lang="pt">`) {
t.Errorf("expected <html lang=\"pt\"> attribute; got:\n%s", got)
}
if !strings.Contains(got, `<h1>Relatório fiscal</h1>`) {
t.Errorf("expected Portuguese title in <h1>; got:\n%s", got)
}
}
+154
View File
@@ -0,0 +1,154 @@
// Package tabledef holds the single definition of the Anexo J report table
// — column configuration, translated headers, data rows and the totals
// footer — shared by every renderer under internal/writing. Living in a
// writing-internal package lets the table and html packages project the
// same structure into their own formats without any of them exposing
// go-pretty types in their public API.
package tabledef
import (
"fmt"
"github.com/biter777/countries"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/nmoniz/any2anexoj/internal"
)
// Translator is the contract the report table renderers rely on for
// human-readable column labels. *writing.Localizer satisfies it.
type Translator interface {
Translate(key string, count int, values map[string]any) string
}
// Column width budgets enforced by the terminal renderer. The html package
// derives its proportional column widths from the same values, keeping both
// renderings in the same strict proportion to each other.
const (
// WidthCountry is the width budget of a country column ("code - name").
WidthCountry = 24
// WidthOther is the width budget of a plain column (code, year, ...).
WidthOther = 12
// WidthEuros is the width budget of a money column ("123.45 €").
WidthEuros = 15
)
// Build constructs a fully configured go-pretty table.Writer from the items
// in aw: one column per report field, translated headers, data rows and the
// totals footer. Each call returns a fresh table so callers can render the
// same data multiple times without duplicating rows, and the returned table
// is not wired to any output.
//
// Change columns, headers or footers here so every renderer stays in sync.
func Build(tr Translator, aw *internal.AggregatorWriter) table.Writer {
tw := table.NewWriter()
tw.SetAutoIndex(true)
tw.SetStyle(table.StyleLight)
tw.SetColumnConfigs([]table.ColumnConfig{
colCountry(1),
colOther(2),
colOther(3),
colOther(4),
colOther(5),
colEuros(6),
colOther(7),
colOther(8),
colOther(9),
colEuros(10),
colEuros(11),
colEuros(12),
colCountry(13),
})
realizationTxt := tr.Translate("realization", 1, nil)
acquisitionTxt := tr.Translate("acquisition", 1, nil)
yearTxt := tr.Translate("year", 1, nil)
monthTxt := tr.Translate("month", 1, nil)
dayTxt := tr.Translate("day", 1, nil)
valorTxt := tr.Translate("value", 1, nil)
tw.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
tw.AppendHeader(table.Row{
tr.Translate("source_country", 1, nil), tr.Translate("code", 1, nil),
yearTxt, monthTxt, dayTxt, valorTxt,
yearTxt, monthTxt, dayTxt, valorTxt,
tr.Translate("expenses", 2, nil), tr.Translate("foreign_tax_paid", 1, nil), tr.Translate("counter_country", 1, nil),
})
for ri := range aw.Iter() {
tw.AppendRow(table.Row{
ri.AssetCountry, ri.Nature,
ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2),
ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2),
ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry,
})
}
sumTxt := tr.Translate("sum", 1, nil)
// The footer lists one cell per column (13) so it never relies on
// go-pretty's implicit short-row padding; the trailing empty cell is the
// counter-country column, which has no total. Totals are formatted with
// StringFixed(2) so they match the two-decimal data rows.
tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned().StringFixed(2), "", "", "", aw.TotalSpent().StringFixed(2), aw.TotalFees().StringFixed(2), aw.TotalTaxes().StringFixed(2), ""}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
return tw
}
func colEuros(n int) table.ColumnConfig {
return table.ColumnConfig{
Number: n,
Align: text.AlignRight,
AlignFooter: text.AlignRight,
AlignHeader: text.AlignRight,
WidthMin: WidthOther,
WidthMax: WidthEuros,
WidthMaxEnforcer: truncateHeader,
Transformer: func(val any) string {
return fmt.Sprintf("%v €", val)
},
TransformerFooter: func(val any) string {
return fmt.Sprintf("%v €", val)
},
}
}
func colOther(n int) table.ColumnConfig {
return table.ColumnConfig{
Number: n,
Align: text.AlignLeft,
AlignFooter: text.AlignLeft,
AlignHeader: text.AlignLeft,
WidthMax: WidthOther,
WidthMaxEnforcer: truncateHeader,
}
}
func colCountry(n int) table.ColumnConfig {
return table.ColumnConfig{
Number: n,
Align: text.AlignLeft,
AlignFooter: text.AlignLeft,
AlignHeader: text.AlignLeft,
WidthMax: WidthCountry,
WidthMaxEnforcer: text.Trim,
Transformer: func(val any) string {
countryCode := val.(int64)
return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name)
},
}
}
// truncateHeader enforces a column's WidthMax on terminal output by cutting
// the text exactly at the width limit — mid-word if that is where it lands —
// and marking the cut with an ellipsis: keeping as many leading runes
// readable beats a tidy word boundary. All indexing is rune-based so
// multibyte labels (e.g. Portuguese) truncate correctly.
func truncateHeader(val string, width int) string {
runes := []rune(val)
if len(runes) <= width {
return val
}
// leave room for the ellipsis
limit := max(width-1, 1)
return string(runes[:limit]) + "…"
}
@@ -0,0 +1,24 @@
package tabledef
import "testing"
func TestTruncateHeader(t *testing.T) {
tests := []struct {
name string
val string
width int
want string
}{
{name: "fits unchanged", val: "TAX PAID ABROAD", width: 15, want: "TAX PAID ABROAD"},
{name: "cut mid-word keeps the most runes", val: "EXPENSES AND CHARGES", width: 15, want: "EXPENSES AND C…"},
{name: "single overlong word is hard cut", val: "ESTRANGEIRO", width: 8, want: "ESTRANG…"},
{name: "cut ignores word boundaries", val: "IMPOSTO PAGO NO ESTRANGEIRO", width: 15, want: "IMPOSTO PAGO N…"},
{name: "multibyte runes count as one", val: "PAÍS DA CONTRAPARTE", width: 12, want: "PAÍS DA CON…"},
{name: "narrow width", val: "ABCD", width: 2, want: "A…"},
}
for _, tt := range tests {
if got := truncateHeader(tt.val, tt.width); got != tt.want {
t.Errorf("%s: truncateHeader(%q, %d) = %q, want %q", tt.name, tt.val, tt.width, got, tt.want)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package writing
import (
"embed"
"encoding/json"
"fmt"
"log/slog"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
//go:embed translations/*.json
var translationsFS embed.FS
type Localizer struct {
*i18n.Localizer
}
func NewLocalizer(lang string) (*Localizer, error) {
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
_, err := bundle.LoadMessageFileFS(translationsFS, "translations/en.json")
if err != nil {
return nil, fmt.Errorf("loading english messages: %w", err)
}
_, err = bundle.LoadMessageFileFS(translationsFS, "translations/pt.json")
if err != nil {
return nil, fmt.Errorf("loading portuguese messages: %w", err)
}
localizer := i18n.NewLocalizer(bundle, lang)
return &Localizer{
Localizer: localizer,
}, nil
}
func (t Localizer) Translate(key string, count int, values map[string]any) string {
txt, err := t.Localize(&i18n.LocalizeConfig{
MessageID: key,
TemplateData: values,
PluralCount: count,
})
if err != nil {
slog.Error("failed to translate message", slog.Any("err", err))
return "<ERROR>"
}
return txt
}
+28
View File
@@ -0,0 +1,28 @@
package writing_test
import (
"testing"
"github.com/nmoniz/any2anexoj/internal/writing"
)
func TestNewLocalizer(t *testing.T) {
tests := []struct {
name string
lang string
}{
{"english", "en"},
{"portuguese", "pt"},
{"english with region", "en-US"},
{"portuguese with region", "pt-BR"},
{"unknown language falls back to default", "!!"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := writing.NewLocalizer(tt.lang)
if err != nil {
t.Fatalf("want success call but failed: %v", err)
}
})
}
}
+34
View File
@@ -0,0 +1,34 @@
// Package table renders an internal.AggregatorWriter as the human-readable
// Anexo J report table for the terminal. The shared table definition it (and
// the html package) renders from lives in internal/writing/internal/tabledef.
package table
import (
"io"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
)
// Printer writes a simple, human readable, table to the provided io.Writer
// for the report items in an AggregatorWriter. Each Render call builds a
// fresh table from the aggregator, so invoking it repeatedly on the same
// instance never duplicates rows.
type Printer struct {
output io.Writer
translator tabledef.Translator
}
func NewPrinter(w io.Writer, tr tabledef.Translator) *Printer {
return &Printer{
output: w,
translator: tr,
}
}
// Render writes the terminal table for aw to the configured output.
func (pp *Printer) Render(aw *internal.AggregatorWriter) {
t := tabledef.Build(pp.translator, aw)
t.SetOutputMirror(pp.output)
t.Render()
}
+84
View File
@@ -0,0 +1,84 @@
package table_test
import (
"bytes"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/writing"
"github.com/nmoniz/any2anexoj/internal/writing/table"
"github.com/shopspring/decimal"
)
func TestPrinter_Render(t *testing.T) {
// Create test data
aw := internal.NewAggregatorWriter()
ctx := t.Context()
// Add some sample report items
err := aw.Write(ctx, internal.ReportItem{
Symbol: "AAPL",
Nature: internal.NatureG01,
BrokerCountry: 826, // United Kingdom
AssetCountry: 840, // United States
BuyValue: decimal.NewFromFloat(100.50),
BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
SellValue: decimal.NewFromFloat(150.75),
SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC),
Fees: decimal.NewFromFloat(2.50),
Taxes: decimal.NewFromFloat(5.00),
})
if err != nil {
t.Fatalf("failed to write first report item: %v", err)
}
err = aw.Write(ctx, internal.ReportItem{
Symbol: "GOOGL",
Nature: internal.NatureG20,
BrokerCountry: 826, // United Kingdom
AssetCountry: 840, // United States
BuyValue: decimal.NewFromFloat(200.00),
BuyTimestamp: time.Date(2023, 3, 10, 0, 0, 0, 0, time.UTC),
SellValue: decimal.NewFromFloat(225.50),
SellTimestamp: time.Date(2023, 9, 5, 0, 0, 0, 0, time.UTC),
Fees: decimal.NewFromFloat(3.00),
Taxes: decimal.NewFromFloat(7.50),
})
if err != nil {
t.Fatalf("failed to write second report item: %v", err)
}
// Create English localizer
localizer, err := writing.NewLocalizer("en")
if err != nil {
t.Fatalf("failed to create localizer: %v", err)
}
// Create writing printer with buffer
var buf bytes.Buffer
pp := table.NewPrinter(&buf, localizer)
// Render the table
pp.Render(aw)
// Get the output
got := buf.String()
// Expected output
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
│ │ │ REALIZATION │ ACQUISITION │ │
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND C… │ TAX PAID ABROAD │ COUNTER COUNTRY │
├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
│ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │
│ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │
├───┼─────────────────────┴──────┴──────┴───────┴─────┼──────────────┼──────┴───────┴─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
│ │ SUM │ 376.25 € │ │ 300.50 € │ 5.50 € │ 12.50 € │ │
└───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘
`
// Compare output
if got != want {
t.Errorf("Printer.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"realization": {
"one": "Realization",
"other": "Realizations"
},
"acquisition": {
"one": "Acquisition",
"other": "Acquisitions"
},
"source_country": {
"one": "Source country",
"other": "Source countries"
},
"counter_country": {
"one": "Counter country",
"other": "Counter countries"
},
"year": {
"one": "Year",
"other": "Years"
},
"month": {
"one": "Month",
"other": "Months"
},
"day": {
"one": "Day",
"other": "Days"
},
"value": {
"one": "Value",
"other": "Values"
},
"code": {
"one": "Code",
"other": "Codes"
},
"expenses": {
"one": "Expense and charge",
"other": "Expenses and charges"
},
"foreign_tax_paid": {
"one": "Tax paid abroad",
"other": "Taxes paid abroad"
},
"sum": {
"one": "Sum",
"other": "Sums"
},
"report_title": {
"one": "Tax report",
"other": "Tax reports"
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"realization": {
"one": "Realização",
"other": "Realizações"
},
"acquisition": {
"one": "Aquisição",
"other": "Aquisições"
},
"source_country": {
"one": "País da fonte",
"other": "Países da fonte"
},
"counter_country": {
"one": "País da contraparte",
"other": "Países da contraparte"
},
"year": {
"one": "Ano",
"other": "Anos"
},
"month": {
"one": "Mês",
"other": "Meses"
},
"day": {
"one": "Dia",
"other": "Dias"
},
"value": {
"one": "Valor",
"other": "Valores"
},
"code": {
"one": "Código",
"other": "Códigos"
},
"expenses": {
"one": "Despesa e encargo",
"other": "Despesas e encargos"
},
"foreign_tax_paid": {
"one": "Imposto pago no estrangeiro",
"other": "Impostos pagos no estrangeiro"
},
"sum": {
"one": "Soma",
"other": "Somas"
},
"report_title": {
"one": "Relatório fiscal",
"other": "Relatórios fiscais"
}
}
+24
View File
@@ -0,0 +1,24 @@
Copyright (c) 2019 Biter, biter2004@yandex.ru. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+27
View File
@@ -0,0 +1,27 @@
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 jedib0t
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+28
View File
@@ -0,0 +1,28 @@
Copyright (c) 2012 Alex Ogier. All rights reserved.
Copyright (c) 2012 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-37
View File
@@ -1,37 +0,0 @@
package main
import (
"fmt"
"log/slog"
"os"
"git.naterciomoniz.net/applications/broker2anexoj/internal"
"git.naterciomoniz.net/applications/broker2anexoj/internal/trading212"
)
func main() {
err := run()
if err != nil {
slog.Error("fatal error", slog.Any("err", err))
}
}
func run() error {
f, err := os.Open("test.csv")
if err != nil {
return fmt.Errorf("open statement: %w", err)
}
reader := trading212.NewRecordReader(f)
reporter := internal.NewReporter(reader)
err = reporter.Run()
if err != nil {
return err
}
slog.Info("Finish processing statement")
return nil
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": [
"config:base",
":disableDependencyDashboard"
],
"packageRules": [
{
"updateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": false
}
],
"postUpdateOptions": [
"gomodTidy"
]
}