diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..16e5cec --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,39 @@ +golang: +- changed-files: + - any-glob-to-any-file: golang/**/* + +scala: +- changed-files: + - any-glob-to-any-file: scala/**/* + +java17: +- changed-files: + - any-glob-to-any-file: java17/**/* + +java8: +- changed-files: + - any-glob-to-any-file: java8/**/* + +typescript: +- changed-files: + - any-glob-to-any-file: typescript/**/* + +python: +- changed-files: + - any-glob-to-any-file: python/**/* + +ruby: +- changed-files: + - any-glob-to-any-file: ruby/**/* + +php: +- changed-files: + - any-glob-to-any-file: php/**/* + +docs: +- changed-files: + - any-glob-to-any-file: docs/**/* + +ci: +- changed-files: + - any-glob-to-any-file: .github/**/* diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000..79f97e8 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,31 @@ +{ + extends: [ + "config:best-practices", + "schedule:earlyMondays", + "group:allNonMajor", + ], + automerge: true, + platformAutomerge: true, + dependencyDashboard: false, + labels: ["dependencies"], + postUpdateOptions: ["gomodTidy", "gomodUpdateImportPaths"], + prHourlyLimit: 0, + prConcurrentLimit: 5, + lockFileMaintenance: { + enabled: true, + schedule: ["before 3am on sunday"], + }, + vulnerabilityAlerts: { + enabled: true, + labels: ["security"], + }, + packageRules: [ + { + # disable language major version upgrades + matchCategories: ["python", "node", "golang", "php", "typescript", "java", "scala", "ruby"], + matchUpdateTypes: ["major"], + enabled: false, + }, + ], + minimumReleaseAge: "7 days", +} diff --git a/.github/workflows/action-lint.yml b/.github/workflows/action-lint.yml new file mode 100644 index 0000000..3918f41 --- /dev/null +++ b/.github/workflows/action-lint.yml @@ -0,0 +1,53 @@ +on: + pull_request: + paths: + - .github/workflows/*.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + checks: write + contents: read + pull-requests: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: true # allow for reviewdog + - uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72 + with: + fail_level: warning + filter_mode: nofilter + level: error + reporter: github-pr-review + ghalint: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - run: | + gh release download "v${GHALINT_VERSION}" \ + --repo suzuki-shunsuke/ghalint \ + --pattern 'ghalint_*_linux_amd64.tar.gz' \ + --output '/tmp/ghalint.tar.gz' + shell: bash + env: + GHALINT_VERSION: 1.5.6 + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + - run: | + cd /tmp + mkdir dist + tar -xvf ghalint.tar.gz -C ./dist/ + sudo mv ./dist/ghalint /usr/local/bin + rm -rf ./dist ghalint.tar.gz + shell: bash + - run: ghalint run diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..4173ae8 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,15 @@ +name: "Pull Request Labeler" +on: + - pull_request_target + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0034bb8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: release + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Create zip archives + run: | + for dir in golang java17 java8 php python ruby scala typescript; do + zip -r "${dir}.zip" "${dir}/" + done + shell: bash + + - name: Create release and upload assets + run: | + TAG="release-$(date +%Y%m%d-%H%M%S)" + gh release create "$TAG" \ + --title "Implementations $TAG" \ + --notes "Auto-generated release of implementation samples" \ + golang.zip java17.zip java8.zip php.zip python.zip ruby.zip scala.zip typescript.zip + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash diff --git a/.github/workflows/test-go.yml b/.github/workflows/test-go.yml new file mode 100644 index 0000000..a227cb5 --- /dev/null +++ b/.github/workflows/test-go.yml @@ -0,0 +1,37 @@ +name: Go + +on: + pull_request: + paths: + - '.github/workflows/test-go.yml' + - 'golang/**.go' + - 'golang/go.mod' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + name: Build + timeout-minutes: 5 + defaults: + run: + working-directory: golang + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: 'golang/go.mod' + cache: true + - run: go mod download + - run: go build ./... + - run: go fmt ./... diff --git a/.github/workflows/test-java17.yml b/.github/workflows/test-java17.yml new file mode 100644 index 0000000..4988642 --- /dev/null +++ b/.github/workflows/test-java17.yml @@ -0,0 +1,36 @@ +name: Java 17 + +on: + pull_request: + paths: + - '.github/workflows/test-java17.yml' + - 'java17/src/**' + - 'java17/pom.xml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + name: Build + timeout-minutes: 5 + defaults: + run: + working-directory: java17 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: '17' + cache: maven + - run: ./mvnw compile -DskipTests diff --git a/.github/workflows/test-java8.yml b/.github/workflows/test-java8.yml new file mode 100644 index 0000000..e69b4de --- /dev/null +++ b/.github/workflows/test-java8.yml @@ -0,0 +1,36 @@ +name: Java 8 + +on: + pull_request: + paths: + - '.github/workflows/test-java8.yml' + - 'java8/src/**' + - 'java8/pom.xml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + name: Build + timeout-minutes: 5 + defaults: + run: + working-directory: java8 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: '8' + cache: maven + - run: ./mvnw compile -DskipTests diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml new file mode 100644 index 0000000..c14aa41 --- /dev/null +++ b/.github/workflows/test-php.yml @@ -0,0 +1,34 @@ +name: PHP + +on: + pull_request: + paths: + - '.github/workflows/test-php.yml' + - 'php/src/**' + - 'php/composer.json' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + name: Build + timeout-minutes: 5 + defaults: + run: + working-directory: php + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.5' + - run: composer install --no-interaction --prefer-dist diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml new file mode 100644 index 0000000..26d115d --- /dev/null +++ b/.github/workflows/test-python.yml @@ -0,0 +1,35 @@ +name: Python + +on: + pull_request: + paths: + - '.github/workflows/test-python.yml' + - 'python/src/**' + - 'python/pyproject.toml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + name: Build + timeout-minutes: 5 + defaults: + run: + working-directory: python + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5 + with: + python-version-file: 'python/pyproject.toml' + cache: pip + - run: pip install . diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml new file mode 100644 index 0000000..81724d3 --- /dev/null +++ b/.github/workflows/test-ruby.yml @@ -0,0 +1,35 @@ +name: Ruby + +on: + pull_request: + paths: + - '.github/workflows/test-ruby.yml' + - 'ruby/lib/**' + - 'ruby/Gemfile' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + name: Build + timeout-minutes: 5 + defaults: + run: + working-directory: ruby + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 # v1 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: ruby diff --git a/.github/workflows/test-scala.yml b/.github/workflows/test-scala.yml new file mode 100644 index 0000000..e9e56c8 --- /dev/null +++ b/.github/workflows/test-scala.yml @@ -0,0 +1,38 @@ +name: Scala + +on: + pull_request: + paths: + - '.github/workflows/test-scala.yml' + - 'scala/src/**' + - 'scala/build.sbt' + - 'scala/project/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + name: Build + timeout-minutes: 5 + defaults: + run: + working-directory: scala + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: '21' + cache: sbt + - uses: sbt/setup-sbt@af116cce31c00823d3903ce687f9cda3a4f19f1b # v1 + - run: sbt compile diff --git a/.github/workflows/test-typescript.yml b/.github/workflows/test-typescript.yml new file mode 100644 index 0000000..e6ccbec --- /dev/null +++ b/.github/workflows/test-typescript.yml @@ -0,0 +1,38 @@ +name: TypeScript + +on: + pull_request: + paths: + - '.github/workflows/test-typescript.yml' + - 'typescript/src/**' + - 'typescript/package.json' + - 'typescript/tsconfig.json' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + name: Build + timeout-minutes: 5 + defaults: + run: + working-directory: typescript + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: 'typescript/package.json' + cache: npm + cache-dependency-path: typescript/package-lock.json + - run: npm ci + - run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f4f038 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.claude/settings.local.json +.serena/ +.claude/settings.local.json +.claude/worktrees/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b9bc2b9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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 2024 FOLIO Co., Ltd. + + 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. diff --git a/README.md b/README.md index 28a4a4d..50db604 100644 --- a/README.md +++ b/README.md @@ -1 +1,53 @@ # Wrap API Example + +## Release + +Kick the [release action](https://github.com/folio-sec/terraform-provider-zoom/actions/workflows/release.yml) manually, then release. + +## Contribution + +See also [CONTRIBUTING.md](CONTRIBUTING.md). + +### DCO Sign-Off Methods + +The sign-off is a simple line at the end of the explanation for the patch, which certifies that you wrote it or otherwise have the right to pass it on as an open-source patch. + +The DCO requires a sign-off message in the following format appear on each commit in the pull request: + +```txt +Signed-off-by: Sample Developer sample@example.com +``` + +The text can either be manually added to your commit body, or you can add either `-s` or `--signoff` to your usual `git` commit commands. + +#### Auto sign-off + +The following method is examples only and are not mandatory. + +```sh +touch .git/hooks/prepare-commit-msg +chmod +x .git/hooks/prepare-commit-msg +``` + +Edit the `prepare-commit-msg` file like: + +```sh +#!/bin/sh + +name=$(git config user.name) +email=$(git config user.email) + +if [ -z "${name}" ]; then + echo "empty git config user.name" + exit 1 +fi + +if [ -z "${email}" ]; then + echo "empty git config user.email" + exit 1 +fi + +git interpret-trailers --if-exists doNothing --trailer \ + "Signed-off-by: ${name} <${email}>" \ + --in-place "$1" +``` diff --git a/ghalint.yaml b/ghalint.yaml new file mode 100644 index 0000000..eccd82b --- /dev/null +++ b/ghalint.yaml @@ -0,0 +1,5 @@ +excludes: + # allow for reviewdog + - policy_name: checkout_persist_credentials_should_be_false + workflow_file_path: .github/workflows/action-lint.yml + job_name: actionlint diff --git a/golang/.gitignore b/golang/.gitignore new file mode 100644 index 0000000..b44c6d3 --- /dev/null +++ b/golang/.gitignore @@ -0,0 +1,30 @@ +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/4d602a24bc5e4bbc2b8cedf08d4e982a80a7dfea/Go.gitignore + +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + + diff --git a/golang/README.md b/golang/README.md new file mode 100644 index 0000000..fbedbd9 --- /dev/null +++ b/golang/README.md @@ -0,0 +1,67 @@ +# サンプルラップサービス + +## 開発 + +- Go 1.22+ + +```shell +# 準備 +git init && git add . && git commit -m init + +# セットアップ +go build ./... + +# テスト実行 +go test ./... +``` + +## サービス概要 + +このアプリケーションはロボアドバイザーサービスのバックエンドです。 + +### 株と評価額 + +- 株には株数(qty)があります(例: 1株、2株) +- 各株には1株あたりの市場価格があります(例: 1株あたり100円) +- 例: 顧客が5株保有している場合、評価額は `5株 × 100円 = 500円` となります + +### ロボアドバイザーサービス + +- **顧客の口座** + - 新規拠出を行うと、口座がすぐに開きます + - 口座の中で資産を管理することになります +- **顧客の資産** + - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します + - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円をいくつかの株で保有する + - 株は価格で保持するのではなく、株数で保持します + - そのため、市場価格に応じて評価額は変わることになります +- **最適ポートフォリオ** + - サービスが管理する、株の評価額ベースの構成比率 + - 例: A株を評価額の30%B株を評価額の70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30% B株95万円*70% になるように努める + - 購入時・売却時・リバランス時には、売買後の資産比率が現在の最適ポートフォリオに近づく形での売買を実施します +- **株の売買** + - 本アプリケーションでは、注文APIを叩くと即時必要な株の売買が成立し資産に反映出来るものとします +- 用語 + - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 + - 全売却注文: 運用中の株を全て売却すること。 + - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + +## 確認観点 + +- 成果を出すこと +- 成果物についての理解・責任を持つこと + +## 課題 + +以下の課題をAIを用いて、あなた自身の言葉・実装で回答してください。 + +1. API/アーキテクチャについてAIと協力しながら自分の言葉で説明してください +2. テストが全て通るようにしてください + - まずは現状のテストを走らせて、どのような内容で落ちているかを説明してください + - また、実装バグがあるため、テストコードは一切変更せず、AIと協力しながら実装を修正してください + - その上で、修正内容について説明をしてください +3. 全売却APIを実装してください + - 全売却後の現金の取り扱いに関しての方針を決めてください + - ストレッチ: 売却後の現金は銀行APIへ連携 +4. (部分売却APIを実装してください) diff --git a/golang/cmd/server/main.go b/golang/cmd/server/main.go new file mode 100644 index 0000000..e8a4cf6 --- /dev/null +++ b/golang/cmd/server/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "fmt" + + "folio/codinginterview/internal/infrastructure/server" +) + +func main() { + _ = server.NewDefaultDummyServer() + fmt.Println("DummyServer initialized.") +} diff --git a/golang/go.mod b/golang/go.mod new file mode 100644 index 0000000..6aebcf4 --- /dev/null +++ b/golang/go.mod @@ -0,0 +1,5 @@ +module folio/codinginterview + +go 1.22 + +require github.com/shopspring/decimal v1.4.0 diff --git a/golang/go.sum b/golang/go.sum new file mode 100644 index 0000000..2ce1d39 --- /dev/null +++ b/golang/go.sum @@ -0,0 +1,2 @@ +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= diff --git a/golang/internal/application/repository/account_repository.go b/golang/internal/application/repository/account_repository.go new file mode 100644 index 0000000..1baabb6 --- /dev/null +++ b/golang/internal/application/repository/account_repository.go @@ -0,0 +1,10 @@ +package repository + +import "folio/codinginterview/internal/domain" + +// AccountRepository は口座管理のリポジトリインターフェースです。 +type AccountRepository interface { + Find(userId domain.UserId) (*domain.Account, error) + Upsert(userId domain.UserId, account domain.Account) error + Exists(userId domain.UserId) (bool, error) +} diff --git a/golang/internal/application/repository/market_price_repository.go b/golang/internal/application/repository/market_price_repository.go new file mode 100644 index 0000000..d84f85c --- /dev/null +++ b/golang/internal/application/repository/market_price_repository.go @@ -0,0 +1,13 @@ +package repository + +import ( + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +// MarketPriceRepository は市場価格のリポジトリインターフェースです。 +type MarketPriceRepository interface { + All() (map[domain.StockSymbol]decimal.Decimal, error) + Update(prices map[domain.StockSymbol]decimal.Decimal) error +} diff --git a/golang/internal/application/repository/portfolio_repository.go b/golang/internal/application/repository/portfolio_repository.go new file mode 100644 index 0000000..88b7c8f --- /dev/null +++ b/golang/internal/application/repository/portfolio_repository.go @@ -0,0 +1,9 @@ +package repository + +import "folio/codinginterview/internal/domain" + +// PortfolioRepository は最適ポートフォリオのリポジトリインターフェースです。 +type PortfolioRepository interface { + Get() (domain.Portfolio, error) + Update(portfolio domain.Portfolio) error +} diff --git a/golang/internal/application/service/asset_service.go b/golang/internal/application/service/asset_service.go new file mode 100644 index 0000000..79d93f6 --- /dev/null +++ b/golang/internal/application/service/asset_service.go @@ -0,0 +1,29 @@ +package service + +import ( + "fmt" + + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +func EvaluateStock(stock domain.Stock, prices map[domain.StockSymbol]decimal.Decimal) (decimal.Decimal, error) { + price, ok := prices[stock.Symbol] + if !ok { + return decimal.Zero, fmt.Errorf("price not found for symbol: %s", stock.Symbol) + } + return stock.Qty.Mul(price), nil +} + +func TotalValuation(account domain.Account, prices map[domain.StockSymbol]decimal.Decimal) (decimal.Decimal, error) { + total := account.Cash + for _, s := range account.Stocks { + val, err := EvaluateStock(s, prices) + if err != nil { + return decimal.Zero, err + } + total = total.Add(val) + } + return total, nil +} diff --git a/golang/internal/application/service/portfolio_service.go b/golang/internal/application/service/portfolio_service.go new file mode 100644 index 0000000..e012b90 --- /dev/null +++ b/golang/internal/application/service/portfolio_service.go @@ -0,0 +1,138 @@ +package service + +import ( + "fmt" + + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +func floor2(x decimal.Decimal) decimal.Decimal { + return x.Truncate(2) +} + +func floor0(x decimal.Decimal) decimal.Decimal { + return x.Truncate(0) +} + +func priceOf(prices map[domain.StockSymbol]decimal.Decimal, symbol domain.StockSymbol) (decimal.Decimal, error) { + p, ok := prices[symbol] + if !ok { + return decimal.Zero, fmt.Errorf("price not found for symbol: %s", symbol) + } + return p, nil +} + +func AllocateNew(amount decimal.Decimal, portfolio domain.Portfolio, prices map[domain.StockSymbol]decimal.Decimal) (domain.Account, error) { + cashFromRate := floor0(amount.Mul(domain.CashRate)) + investable := amount.Sub(cashFromRate) + + stocks := make([]domain.Stock, 0, len(portfolio.Items)) + for _, item := range portfolio.Items { + price, err := priceOf(prices, item.Symbol) + if err != nil { + return domain.Account{}, err + } + qty := floor2(investable.Mul(item.Rate).Div(price)) + stocks = append(stocks, domain.Stock{Symbol: item.Symbol, Qty: qty}) + } + + usedForStocks := decimal.Zero + for _, s := range stocks { + price, err := priceOf(prices, s.Symbol) + if err != nil { + return domain.Account{}, err + } + usedForStocks = usedForStocks.Add(s.Qty.Mul(price)) + } + residual := investable.Sub(usedForStocks) + + return domain.Account{Cash: cashFromRate.Add(residual), Stocks: stocks}, nil +} + +func AllocateAdditional(account domain.Account, amount decimal.Decimal, portfolio domain.Portfolio, prices map[domain.StockSymbol]decimal.Decimal) (domain.Account, error) { + totalAfterVal, err := TotalValuation(account, prices) + if err != nil { + return domain.Account{}, err + } + totalAfter := totalAfterVal.Add(amount) + targetCash := floor0(totalAfter.Mul(domain.CashRate)) + investable := totalAfter.Sub(targetCash) + + currentQty := make(map[domain.StockSymbol]decimal.Decimal) + for _, s := range account.Stocks { + currentQty[s.Symbol] = s.Qty + } + + portfolioSymbols := make(map[domain.StockSymbol]struct{}) + for _, item := range portfolio.Items { + portfolioSymbols[item.Symbol] = struct{}{} + } + + newPortfolioStocks := make([]domain.Stock, 0, len(portfolio.Items)) + for _, item := range portfolio.Items { + price, err := priceOf(prices, item.Symbol) + if err != nil { + return domain.Account{}, err + } + targetQty := floor2(investable.Mul(item.Rate).Div(price)) + current := currentQty[item.Symbol] + finalQty := targetQty + if current.GreaterThan(targetQty) { + finalQty = current + } + newPortfolioStocks = append(newPortfolioStocks, domain.Stock{Symbol: item.Symbol, Qty: finalQty}) + } + + preservedStocks := make([]domain.Stock, 0) + for _, s := range account.Stocks { + if _, inPortfolio := portfolioSymbols[s.Symbol]; !inPortfolio { + preservedStocks = append(preservedStocks, s) + } + } + + allStocks := append(newPortfolioStocks, preservedStocks...) + + finalValuation := decimal.Zero + for _, s := range allStocks { + price, err := priceOf(prices, s.Symbol) + if err != nil { + return domain.Account{}, err + } + finalValuation = finalValuation.Add(s.Qty.Mul(price)) + } + finalCash := totalAfter.Sub(finalValuation) + + return domain.Account{Cash: finalCash, Stocks: allStocks}, nil +} + +func Rebalance(account domain.Account, portfolio domain.Portfolio, prices map[domain.StockSymbol]decimal.Decimal) (domain.Account, error) { + // XXX this implementation might not be correct + investable, err := TotalValuation(account, prices) + if err != nil { + return domain.Account{}, err + } + + newStocks := make([]domain.Stock, 0, len(portfolio.Items)) + for _, item := range portfolio.Items { + price, err := priceOf(prices, item.Symbol) + if err != nil { + return domain.Account{}, err + } + qty := floor2(investable.Mul(item.Rate).Div(price)) + newStocks = append(newStocks, domain.Stock{Symbol: item.Symbol, Qty: qty}) + } + + finalValuation := decimal.Zero + for _, s := range newStocks { + price, err := priceOf(prices, s.Symbol) + if err != nil { + return domain.Account{}, err + } + finalValuation = finalValuation.Add(s.Qty.Mul(price)) + } + finalCash := investable.Sub(finalValuation) + + return domain.Account{Cash: finalCash, Stocks: newStocks}, nil +} diff --git a/golang/internal/application/usecase/asset/get_asset_usecase.go b/golang/internal/application/usecase/asset/get_asset_usecase.go new file mode 100644 index 0000000..3262c9d --- /dev/null +++ b/golang/internal/application/usecase/asset/get_asset_usecase.go @@ -0,0 +1,62 @@ +package asset + +import ( + "errors" + + "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/application/service" + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +var ErrUserNotFound = errors.New("user not found") + +type GetAssetStockOutput struct { + Symbol domain.StockSymbol + EvaluationAmount decimal.Decimal +} + +type GetAssetUsecaseInput struct { + UserId domain.UserId +} + +type GetAssetUsecaseOutput struct { + CashAmount decimal.Decimal + Stocks []GetAssetStockOutput +} + +type GetAssetUsecase struct { + accountRepo repository.AccountRepository + marketPriceRepo repository.MarketPriceRepository +} + +func NewGetAssetUsecase(accountRepo repository.AccountRepository, marketPriceRepo repository.MarketPriceRepository) *GetAssetUsecase { + return &GetAssetUsecase{accountRepo: accountRepo, marketPriceRepo: marketPriceRepo} +} + +func (u *GetAssetUsecase) Run(input GetAssetUsecaseInput) (GetAssetUsecaseOutput, error) { + account, err := u.accountRepo.Find(input.UserId) + if err != nil { + return GetAssetUsecaseOutput{}, err + } + if account == nil { + return GetAssetUsecaseOutput{}, ErrUserNotFound + } + + prices, err := u.marketPriceRepo.All() + if err != nil { + return GetAssetUsecaseOutput{}, err + } + + stocks := make([]GetAssetStockOutput, 0, len(account.Stocks)) + for _, s := range account.Stocks { + evalAmount, err := service.EvaluateStock(s, prices) + if err != nil { + return GetAssetUsecaseOutput{}, err + } + stocks = append(stocks, GetAssetStockOutput{Symbol: s.Symbol, EvaluationAmount: evalAmount}) + } + + return GetAssetUsecaseOutput{CashAmount: account.Cash, Stocks: stocks}, nil +} diff --git a/golang/internal/application/usecase/market_price/update_market_price_usecase.go b/golang/internal/application/usecase/market_price/update_market_price_usecase.go new file mode 100644 index 0000000..85a6c18 --- /dev/null +++ b/golang/internal/application/usecase/market_price/update_market_price_usecase.go @@ -0,0 +1,33 @@ +package marketprice + +import ( + "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +type UpdateMarketPriceItemInput struct { + Symbol domain.StockSymbol + MarketPrice decimal.Decimal +} + +type UpdateMarketPriceUsecaseInput struct { + Items []UpdateMarketPriceItemInput +} + +type UpdateMarketPriceUsecase struct { + marketPriceRepo repository.MarketPriceRepository +} + +func NewUpdateMarketPriceUsecase(marketPriceRepo repository.MarketPriceRepository) *UpdateMarketPriceUsecase { + return &UpdateMarketPriceUsecase{marketPriceRepo: marketPriceRepo} +} + +func (u *UpdateMarketPriceUsecase) Run(input UpdateMarketPriceUsecaseInput) error { + prices := make(map[domain.StockSymbol]decimal.Decimal, len(input.Items)) + for _, item := range input.Items { + prices[item.Symbol] = item.MarketPrice + } + return u.marketPriceRepo.Update(prices) +} diff --git a/golang/internal/application/usecase/order/additional_buy_order_usecase.go b/golang/internal/application/usecase/order/additional_buy_order_usecase.go new file mode 100644 index 0000000..b7635e9 --- /dev/null +++ b/golang/internal/application/usecase/order/additional_buy_order_usecase.go @@ -0,0 +1,70 @@ +package order + +import ( + "errors" + + "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/application/service" + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +var ( + ErrAdditionalBuyUserNotFound = errors.New("user has no live account") + ErrAdditionalBuyAmountTooSmall = errors.New("amount is too small") +) + +type AdditionalBuyOrderUsecaseInput struct { + UserId domain.UserId + Amount decimal.Decimal +} + +type AdditionalBuyOrderUsecase struct { + accountRepo repository.AccountRepository + portfolioRepo repository.PortfolioRepository + marketPriceRepo repository.MarketPriceRepository +} + +func NewAdditionalBuyOrderUsecase( + accountRepo repository.AccountRepository, + portfolioRepo repository.PortfolioRepository, + marketPriceRepo repository.MarketPriceRepository, +) *AdditionalBuyOrderUsecase { + return &AdditionalBuyOrderUsecase{ + accountRepo: accountRepo, + portfolioRepo: portfolioRepo, + marketPriceRepo: marketPriceRepo, + } +} + +func (u *AdditionalBuyOrderUsecase) Run(input AdditionalBuyOrderUsecaseInput) error { + if input.Amount.LessThan(domain.MinOperationAmount) { + return ErrAdditionalBuyAmountTooSmall + } + + account, err := u.accountRepo.Find(input.UserId) + if err != nil { + return err + } + if account == nil { + return ErrAdditionalBuyUserNotFound + } + + portfolio, err := u.portfolioRepo.Get() + if err != nil { + return err + } + + prices, err := u.marketPriceRepo.All() + if err != nil { + return err + } + + updated, err := service.AllocateAdditional(*account, input.Amount, portfolio, prices) + if err != nil { + return err + } + + return u.accountRepo.Upsert(input.UserId, updated) +} diff --git a/golang/internal/application/usecase/order/new_contribution_order_usecase.go b/golang/internal/application/usecase/order/new_contribution_order_usecase.go new file mode 100644 index 0000000..4a60986 --- /dev/null +++ b/golang/internal/application/usecase/order/new_contribution_order_usecase.go @@ -0,0 +1,70 @@ +package order + +import ( + "errors" + + "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/application/service" + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +var ( + ErrNewContributionUserAlreadyExists = errors.New("user already has account") + ErrNewContributionAmountTooSmall = errors.New("amount is too small") +) + +type NewContributionOrderUsecaseInput struct { + UserId domain.UserId + Amount decimal.Decimal +} + +type NewContributionOrderUsecase struct { + accountRepo repository.AccountRepository + portfolioRepo repository.PortfolioRepository + marketPriceRepo repository.MarketPriceRepository +} + +func NewNewContributionOrderUsecase( + accountRepo repository.AccountRepository, + portfolioRepo repository.PortfolioRepository, + marketPriceRepo repository.MarketPriceRepository, +) *NewContributionOrderUsecase { + return &NewContributionOrderUsecase{ + accountRepo: accountRepo, + portfolioRepo: portfolioRepo, + marketPriceRepo: marketPriceRepo, + } +} + +func (u *NewContributionOrderUsecase) Run(input NewContributionOrderUsecaseInput) error { + if input.Amount.LessThan(domain.MinOperationAmount) { + return ErrNewContributionAmountTooSmall + } + + exists, err := u.accountRepo.Exists(input.UserId) + if err != nil { + return err + } + if exists { + return ErrNewContributionUserAlreadyExists + } + + portfolio, err := u.portfolioRepo.Get() + if err != nil { + return err + } + + prices, err := u.marketPriceRepo.All() + if err != nil { + return err + } + + account, err := service.AllocateNew(input.Amount, portfolio, prices) + if err != nil { + return err + } + + return u.accountRepo.Upsert(input.UserId, account) +} diff --git a/golang/internal/application/usecase/order/rebalance_order_usecase.go b/golang/internal/application/usecase/order/rebalance_order_usecase.go new file mode 100644 index 0000000..39f2698 --- /dev/null +++ b/golang/internal/application/usecase/order/rebalance_order_usecase.go @@ -0,0 +1,60 @@ +package order + +import ( + "errors" + + "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/application/service" + "folio/codinginterview/internal/domain" +) + +var ErrRebalanceUserNotFound = errors.New("user has no live account") + +type RebalanceOrderUsecaseInput struct { + UserId domain.UserId +} + +type RebalanceOrderUsecase struct { + accountRepo repository.AccountRepository + portfolioRepo repository.PortfolioRepository + marketPriceRepo repository.MarketPriceRepository +} + +func NewRebalanceOrderUsecase( + accountRepo repository.AccountRepository, + portfolioRepo repository.PortfolioRepository, + marketPriceRepo repository.MarketPriceRepository, +) *RebalanceOrderUsecase { + return &RebalanceOrderUsecase{ + accountRepo: accountRepo, + portfolioRepo: portfolioRepo, + marketPriceRepo: marketPriceRepo, + } +} + +func (u *RebalanceOrderUsecase) Run(input RebalanceOrderUsecaseInput) error { + account, err := u.accountRepo.Find(input.UserId) + if err != nil { + return err + } + if account == nil { + return ErrRebalanceUserNotFound + } + + portfolio, err := u.portfolioRepo.Get() + if err != nil { + return err + } + + prices, err := u.marketPriceRepo.All() + if err != nil { + return err + } + + updated, err := service.Rebalance(*account, portfolio, prices) + if err != nil { + return err + } + + return u.accountRepo.Upsert(input.UserId, updated) +} diff --git a/golang/internal/application/usecase/portfolio/get_latest_portfolio_usecase.go b/golang/internal/application/usecase/portfolio/get_latest_portfolio_usecase.go new file mode 100644 index 0000000..4d1fa37 --- /dev/null +++ b/golang/internal/application/usecase/portfolio/get_latest_portfolio_usecase.go @@ -0,0 +1,37 @@ +package portfolio + +import ( + "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +type GetLatestPortfolioItemOutput struct { + Symbol domain.StockSymbol + Rate decimal.Decimal +} + +type GetLatestPortfolioUsecaseOutput struct { + Items []GetLatestPortfolioItemOutput +} + +type GetLatestPortfolioUsecase struct { + portfolioRepo repository.PortfolioRepository +} + +func NewGetLatestPortfolioUsecase(portfolioRepo repository.PortfolioRepository) *GetLatestPortfolioUsecase { + return &GetLatestPortfolioUsecase{portfolioRepo: portfolioRepo} +} + +func (u *GetLatestPortfolioUsecase) Run() (GetLatestPortfolioUsecaseOutput, error) { + p, err := u.portfolioRepo.Get() + if err != nil { + return GetLatestPortfolioUsecaseOutput{}, err + } + items := make([]GetLatestPortfolioItemOutput, 0, len(p.Items)) + for _, item := range p.Items { + items = append(items, GetLatestPortfolioItemOutput{Symbol: item.Symbol, Rate: item.Rate}) + } + return GetLatestPortfolioUsecaseOutput{Items: items}, nil +} diff --git a/golang/internal/application/usecase/portfolio/update_portfolio_usecase.go b/golang/internal/application/usecase/portfolio/update_portfolio_usecase.go new file mode 100644 index 0000000..d730a41 --- /dev/null +++ b/golang/internal/application/usecase/portfolio/update_portfolio_usecase.go @@ -0,0 +1,55 @@ +package portfolio + +import ( + "errors" + + "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +type InvalidPortfolioError struct { + Reason string +} + +func (e *InvalidPortfolioError) Error() string { + return e.Reason +} + +type UpdatePortfolioItemInput struct { + Symbol domain.StockSymbol + Rate decimal.Decimal +} + +type UpdatePortfolioUsecaseInput struct { + Items []UpdatePortfolioItemInput +} + +type UpdatePortfolioUsecase struct { + portfolioRepo repository.PortfolioRepository +} + +func NewUpdatePortfolioUsecase(portfolioRepo repository.PortfolioRepository) *UpdatePortfolioUsecase { + return &UpdatePortfolioUsecase{portfolioRepo: portfolioRepo} +} + +func (u *UpdatePortfolioUsecase) Run(input UpdatePortfolioUsecaseInput) error { + items := make([]domain.PortfolioItem, 0, len(input.Items)) + for _, i := range input.Items { + items = append(items, domain.PortfolioItem{Symbol: i.Symbol, Rate: i.Rate}) + } + p, err := domain.NewPortfolio(items) + if err != nil { + return &InvalidPortfolioError{Reason: err.Error()} + } + return u.portfolioRepo.Update(p) +} + +func AsInvalidPortfolioError(err error) (*InvalidPortfolioError, bool) { + var e *InvalidPortfolioError + if errors.As(err, &e) { + return e, true + } + return nil, false +} diff --git a/golang/internal/domain/constants.go b/golang/internal/domain/constants.go new file mode 100644 index 0000000..59c28ad --- /dev/null +++ b/golang/internal/domain/constants.go @@ -0,0 +1,25 @@ +package domain + +import "github.com/shopspring/decimal" + +var ( + CashRate = decimal.RequireFromString("0.05") + MinOperationAmount = decimal.NewFromInt(10000) + SupportedSymbols = []StockSymbol{Toyopa, Somy} + + InitialPrices = map[StockSymbol]decimal.Decimal{ + Toyopa: decimal.RequireFromString("4.2135"), + Somy: decimal.RequireFromString("1.2345"), + } +) + +func MustInitialPortfolio() Portfolio { + p, err := NewPortfolio([]PortfolioItem{ + {Symbol: Toyopa, Rate: decimal.RequireFromString("0.40")}, + {Symbol: Somy, Rate: decimal.RequireFromString("0.60")}, + }) + if err != nil { + panic(err) + } + return p +} diff --git a/golang/internal/domain/stock.go b/golang/internal/domain/stock.go new file mode 100644 index 0000000..1faddba --- /dev/null +++ b/golang/internal/domain/stock.go @@ -0,0 +1,48 @@ +package domain + +import ( + "errors" + "fmt" + + "github.com/shopspring/decimal" +) + +type Stock struct { + Symbol StockSymbol + Qty decimal.Decimal +} + +type PortfolioItem struct { + Symbol StockSymbol + Rate decimal.Decimal +} + +type Portfolio struct { + Items []PortfolioItem +} + +func NewPortfolio(items []PortfolioItem) (Portfolio, error) { + if len(items) == 0 { + return Portfolio{}, errors.New("portfolio must have at least one item") + } + sum := decimal.Zero + for _, item := range items { + sum = sum.Add(item.Rate) + } + if !sum.Equal(decimal.NewFromInt(1)) { + return Portfolio{}, fmt.Errorf("portfolio rates must sum to 1, got %s", sum.String()) + } + seen := make(map[StockSymbol]struct{}) + for _, item := range items { + if _, ok := seen[item.Symbol]; ok { + return Portfolio{}, errors.New("portfolio must not have duplicate symbols") + } + seen[item.Symbol] = struct{}{} + } + return Portfolio{Items: items}, nil +} + +type Account struct { + Cash decimal.Decimal + Stocks []Stock +} diff --git a/golang/internal/domain/stock_symbol.go b/golang/internal/domain/stock_symbol.go new file mode 100644 index 0000000..e8e36eb --- /dev/null +++ b/golang/internal/domain/stock_symbol.go @@ -0,0 +1,21 @@ +package domain + +import "fmt" + +type StockSymbol string + +const ( + Toyopa StockSymbol = "Toyopa" + Somy StockSymbol = "Somy" +) + +func StockSymbolFromString(s string) (StockSymbol, error) { + switch s { + case "Toyopa": + return Toyopa, nil + case "Somy": + return Somy, nil + default: + return "", fmt.Errorf("unknown symbol: %s", s) + } +} diff --git a/golang/internal/domain/user_id.go b/golang/internal/domain/user_id.go new file mode 100644 index 0000000..518e634 --- /dev/null +++ b/golang/internal/domain/user_id.go @@ -0,0 +1,14 @@ +package domain + +import "errors" + +type UserId struct { + Value string +} + +func NewUserId(s string) (UserId, error) { + if s == "" { + return UserId{}, errors.New("userId must not be empty") + } + return UserId{Value: s}, nil +} diff --git a/golang/internal/infrastructure/repository/account_repository_impl.go b/golang/internal/infrastructure/repository/account_repository_impl.go new file mode 100644 index 0000000..e8420fb --- /dev/null +++ b/golang/internal/infrastructure/repository/account_repository_impl.go @@ -0,0 +1,43 @@ +package repository + +import ( + "sync" + + apprepository "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/domain" +) + +type AccountRepositoryImpl struct { + mu sync.RWMutex + store map[string]domain.Account +} + +func NewAccountRepositoryImpl() apprepository.AccountRepository { + return &AccountRepositoryImpl{ + store: make(map[string]domain.Account), + } +} + +func (r *AccountRepositoryImpl) Find(userId domain.UserId) (*domain.Account, error) { + r.mu.RLock() + defer r.mu.RUnlock() + account, ok := r.store[userId.Value] + if !ok { + return nil, nil + } + return &account, nil +} + +func (r *AccountRepositoryImpl) Upsert(userId domain.UserId, account domain.Account) error { + r.mu.Lock() + defer r.mu.Unlock() + r.store[userId.Value] = account + return nil +} + +func (r *AccountRepositoryImpl) Exists(userId domain.UserId) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + _, ok := r.store[userId.Value] + return ok, nil +} diff --git a/golang/internal/infrastructure/repository/market_price_repository_impl.go b/golang/internal/infrastructure/repository/market_price_repository_impl.go new file mode 100644 index 0000000..f2d66e2 --- /dev/null +++ b/golang/internal/infrastructure/repository/market_price_repository_impl.go @@ -0,0 +1,40 @@ +package repository + +import ( + "sync" + + apprepository "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +type MarketPriceRepositoryImpl struct { + mu sync.RWMutex + prices map[domain.StockSymbol]decimal.Decimal +} + +func NewMarketPriceRepositoryImpl() apprepository.MarketPriceRepository { + prices := make(map[domain.StockSymbol]decimal.Decimal) + for k, v := range domain.InitialPrices { + prices[k] = v + } + return &MarketPriceRepositoryImpl{prices: prices} +} + +func (r *MarketPriceRepositoryImpl) All() (map[domain.StockSymbol]decimal.Decimal, error) { + r.mu.RLock() + defer r.mu.RUnlock() + result := make(map[domain.StockSymbol]decimal.Decimal, len(r.prices)) + for k, v := range r.prices { + result[k] = v + } + return result, nil +} + +func (r *MarketPriceRepositoryImpl) Update(prices map[domain.StockSymbol]decimal.Decimal) error { + r.mu.Lock() + defer r.mu.Unlock() + r.prices = prices + return nil +} diff --git a/golang/internal/infrastructure/repository/portfolio_repository_impl.go b/golang/internal/infrastructure/repository/portfolio_repository_impl.go new file mode 100644 index 0000000..5c638b0 --- /dev/null +++ b/golang/internal/infrastructure/repository/portfolio_repository_impl.go @@ -0,0 +1,32 @@ +package repository + +import ( + "sync" + + apprepository "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/domain" +) + +type PortfolioRepositoryImpl struct { + mu sync.RWMutex + portfolio domain.Portfolio +} + +func NewPortfolioRepositoryImpl() apprepository.PortfolioRepository { + return &PortfolioRepositoryImpl{ + portfolio: domain.MustInitialPortfolio(), + } +} + +func (r *PortfolioRepositoryImpl) Get() (domain.Portfolio, error) { + r.mu.RLock() + defer r.mu.RUnlock() + return r.portfolio, nil +} + +func (r *PortfolioRepositoryImpl) Update(portfolio domain.Portfolio) error { + r.mu.Lock() + defer r.mu.Unlock() + r.portfolio = portfolio + return nil +} diff --git a/golang/internal/infrastructure/server/dummy_server.go b/golang/internal/infrastructure/server/dummy_server.go new file mode 100644 index 0000000..9a59d5f --- /dev/null +++ b/golang/internal/infrastructure/server/dummy_server.go @@ -0,0 +1,43 @@ +package server + +import ( + marketprice "folio/codinginterview/internal/application/usecase/market_price" + "folio/codinginterview/internal/application/usecase/order" + portfoliousecase "folio/codinginterview/internal/application/usecase/portfolio" + assetusecase "folio/codinginterview/internal/application/usecase/asset" + infrarepo "folio/codinginterview/internal/infrastructure/repository" + "folio/codinginterview/internal/presentation" +) + +type DummyServer struct { + AssetController *presentation.AssetController + PortfolioController *presentation.PortfolioController + OrderController *presentation.OrderController + MarketPriceController *presentation.MarketPriceController +} + +func NewDefaultDummyServer() *DummyServer { + portfolioRepo := infrarepo.NewPortfolioRepositoryImpl() + accountRepo := infrarepo.NewAccountRepositoryImpl() + marketPriceRepo := infrarepo.NewMarketPriceRepositoryImpl() + + getAssetUsecase := assetusecase.NewGetAssetUsecase(accountRepo, marketPriceRepo) + getLatestPortfolioUsecase := portfoliousecase.NewGetLatestPortfolioUsecase(portfolioRepo) + updatePortfolioUsecase := portfoliousecase.NewUpdatePortfolioUsecase(portfolioRepo) + updateMarketPriceUsecase := marketprice.NewUpdateMarketPriceUsecase(marketPriceRepo) + newContributionOrderUsecase := order.NewNewContributionOrderUsecase(accountRepo, portfolioRepo, marketPriceRepo) + additionalBuyOrderUsecase := order.NewAdditionalBuyOrderUsecase(accountRepo, portfolioRepo, marketPriceRepo) + rebalanceOrderUsecase := order.NewRebalanceOrderUsecase(accountRepo, portfolioRepo, marketPriceRepo) + + assetController := presentation.NewAssetController(getAssetUsecase) + portfolioController := presentation.NewPortfolioController(getLatestPortfolioUsecase, updatePortfolioUsecase) + orderController := presentation.NewOrderController(newContributionOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase) + marketPriceController := presentation.NewMarketPriceController(updateMarketPriceUsecase) + + return &DummyServer{ + AssetController: assetController, + PortfolioController: portfolioController, + OrderController: orderController, + MarketPriceController: marketPriceController, + } +} diff --git a/golang/internal/presentation/asset_controller.go b/golang/internal/presentation/asset_controller.go new file mode 100644 index 0000000..a9967ee --- /dev/null +++ b/golang/internal/presentation/asset_controller.go @@ -0,0 +1,56 @@ +package presentation + +import ( + "errors" + + "folio/codinginterview/internal/application/usecase/asset" +) + +type StockDto struct { + Symbol string + EvaluationAmount string +} + +type GetAssetRequest struct { + UserId string +} + +type GetAssetResponse struct { + CashAmount string + Stocks []StockDto +} + +type AssetController struct { + getAssetUsecase *asset.GetAssetUsecase +} + +func NewAssetController(getAssetUsecase *asset.GetAssetUsecase) *AssetController { + return &AssetController{getAssetUsecase: getAssetUsecase} +} + +func (c *AssetController) GetAsset(req GetAssetRequest) (GetAssetResponse, error) { + uid, err := parseUserId(req.UserId) + if err != nil { + return GetAssetResponse{}, err + } + + out, err := c.getAssetUsecase.Run(asset.GetAssetUsecaseInput{UserId: uid}) + if err != nil { + if errors.Is(err, asset.ErrUserNotFound) { + return GetAssetResponse{}, newBadRequest("user not found") + } + return GetAssetResponse{}, err + } + + stocks := make([]StockDto, 0, len(out.Stocks)) + for _, s := range out.Stocks { + stocks = append(stocks, StockDto{ + Symbol: string(s.Symbol), + EvaluationAmount: s.EvaluationAmount.String(), + }) + } + return GetAssetResponse{ + CashAmount: out.CashAmount.String(), + Stocks: stocks, + }, nil +} diff --git a/golang/internal/presentation/exceptions.go b/golang/internal/presentation/exceptions.go new file mode 100644 index 0000000..ebbab68 --- /dev/null +++ b/golang/internal/presentation/exceptions.go @@ -0,0 +1,18 @@ +package presentation + +type BadRequestError struct { + Message string +} + +func (e *BadRequestError) Error() string { + return e.Message +} + +func newBadRequest(msg string) error { + return &BadRequestError{Message: msg} +} + +func IsBadRequestError(err error) (*BadRequestError, bool) { + e, ok := err.(*BadRequestError) + return e, ok +} diff --git a/golang/internal/presentation/market_price_controller.go b/golang/internal/presentation/market_price_controller.go new file mode 100644 index 0000000..95cdd84 --- /dev/null +++ b/golang/internal/presentation/market_price_controller.go @@ -0,0 +1,44 @@ +package presentation + +import ( + "fmt" + + marketprice "folio/codinginterview/internal/application/usecase/market_price" + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +type MarketPriceItemDto struct { + Symbol string + MarketPrice string +} + +type UpdateMarketPriceRequest struct { + MarketPrices []MarketPriceItemDto +} + +type MarketPriceController struct { + updateMarketPriceUsecase *marketprice.UpdateMarketPriceUsecase +} + +func NewMarketPriceController(updateMarketPriceUsecase *marketprice.UpdateMarketPriceUsecase) *MarketPriceController { + return &MarketPriceController{updateMarketPriceUsecase: updateMarketPriceUsecase} +} + +func (c *MarketPriceController) UpdateMarketPrice(req UpdateMarketPriceRequest) error { + items := make([]marketprice.UpdateMarketPriceItemInput, 0, len(req.MarketPrices)) + for _, dto := range req.MarketPrices { + sym, err := domain.StockSymbolFromString(dto.Symbol) + if err != nil { + return newBadRequest(fmt.Sprintf("unknown symbol: %s", dto.Symbol)) + } + price, err := decimal.NewFromString(dto.MarketPrice) + if err != nil { + return newBadRequest(fmt.Sprintf("invalid market_price: %s", dto.MarketPrice)) + } + items = append(items, marketprice.UpdateMarketPriceItemInput{Symbol: sym, MarketPrice: price}) + } + + return c.updateMarketPriceUsecase.Run(marketprice.UpdateMarketPriceUsecaseInput{Items: items}) +} diff --git a/golang/internal/presentation/order_controller.go b/golang/internal/presentation/order_controller.go new file mode 100644 index 0000000..49a1306 --- /dev/null +++ b/golang/internal/presentation/order_controller.go @@ -0,0 +1,101 @@ +package presentation + +import ( + "errors" + + "folio/codinginterview/internal/application/usecase/order" +) + +type NewContributionOrderRequest struct { + UserId string + Amount string +} + +type AdditionalContributionOrderRequest struct { + UserId string + Amount string +} + +type RebalanceOrderRequest struct { + UserId string +} + +type OrderController struct { + newContributionOrderUsecase *order.NewContributionOrderUsecase + additionalBuyOrderUsecase *order.AdditionalBuyOrderUsecase + rebalanceOrderUsecase *order.RebalanceOrderUsecase +} + +func NewOrderController( + newContributionOrderUsecase *order.NewContributionOrderUsecase, + additionalBuyOrderUsecase *order.AdditionalBuyOrderUsecase, + rebalanceOrderUsecase *order.RebalanceOrderUsecase, +) *OrderController { + return &OrderController{ + newContributionOrderUsecase: newContributionOrderUsecase, + additionalBuyOrderUsecase: additionalBuyOrderUsecase, + rebalanceOrderUsecase: rebalanceOrderUsecase, + } +} + +func (c *OrderController) NewContributionOrder(req NewContributionOrderRequest) error { + uid, err := parseUserId(req.UserId) + if err != nil { + return err + } + amt, err := parseAmount(req.Amount) + if err != nil { + return err + } + + err = c.newContributionOrderUsecase.Run(order.NewContributionOrderUsecaseInput{UserId: uid, Amount: amt}) + if err != nil { + if errors.Is(err, order.ErrNewContributionUserAlreadyExists) { + return newBadRequest("user already has account") + } + if errors.Is(err, order.ErrNewContributionAmountTooSmall) { + return newBadRequest("amount is too small") + } + return err + } + return nil +} + +func (c *OrderController) AdditionalContributionOrder(req AdditionalContributionOrderRequest) error { + uid, err := parseUserId(req.UserId) + if err != nil { + return err + } + amt, err := parseAmount(req.Amount) + if err != nil { + return err + } + + err = c.additionalBuyOrderUsecase.Run(order.AdditionalBuyOrderUsecaseInput{UserId: uid, Amount: amt}) + if err != nil { + if errors.Is(err, order.ErrAdditionalBuyUserNotFound) { + return newBadRequest("user has no live account") + } + if errors.Is(err, order.ErrAdditionalBuyAmountTooSmall) { + return newBadRequest("amount is too small") + } + return err + } + return nil +} + +func (c *OrderController) RebalanceOrder(req RebalanceOrderRequest) error { + uid, err := parseUserId(req.UserId) + if err != nil { + return err + } + + err = c.rebalanceOrderUsecase.Run(order.RebalanceOrderUsecaseInput{UserId: uid}) + if err != nil { + if errors.Is(err, order.ErrRebalanceUserNotFound) { + return newBadRequest("user has no live account") + } + return err + } + return nil +} diff --git a/golang/internal/presentation/portfolio_controller.go b/golang/internal/presentation/portfolio_controller.go new file mode 100644 index 0000000..5d101dd --- /dev/null +++ b/golang/internal/presentation/portfolio_controller.go @@ -0,0 +1,77 @@ +package presentation + +import ( + "fmt" + + "folio/codinginterview/internal/application/usecase/portfolio" + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +type PortfolioItemDto struct { + Symbol string + Rate string +} + +type GetOptimalPortfolioResponse struct { + Portfolios []PortfolioItemDto +} + +type UpdateOptimalPortfolioRequest struct { + Portfolios []PortfolioItemDto +} + +type PortfolioController struct { + getLatestPortfolioUsecase *portfolio.GetLatestPortfolioUsecase + updatePortfolioUsecase *portfolio.UpdatePortfolioUsecase +} + +func NewPortfolioController( + getLatestPortfolioUsecase *portfolio.GetLatestPortfolioUsecase, + updatePortfolioUsecase *portfolio.UpdatePortfolioUsecase, +) *PortfolioController { + return &PortfolioController{ + getLatestPortfolioUsecase: getLatestPortfolioUsecase, + updatePortfolioUsecase: updatePortfolioUsecase, + } +} + +func (c *PortfolioController) GetOptimalPortfolio() (GetOptimalPortfolioResponse, error) { + out, err := c.getLatestPortfolioUsecase.Run() + if err != nil { + return GetOptimalPortfolioResponse{}, err + } + portfolios := make([]PortfolioItemDto, 0, len(out.Items)) + for _, item := range out.Items { + portfolios = append(portfolios, PortfolioItemDto{ + Symbol: string(item.Symbol), + Rate: item.Rate.String(), + }) + } + return GetOptimalPortfolioResponse{Portfolios: portfolios}, nil +} + +func (c *PortfolioController) UpdateOptimalPortfolio(req UpdateOptimalPortfolioRequest) error { + items := make([]portfolio.UpdatePortfolioItemInput, 0, len(req.Portfolios)) + for _, dto := range req.Portfolios { + sym, err := domain.StockSymbolFromString(dto.Symbol) + if err != nil { + return newBadRequest(fmt.Sprintf("unknown symbol: %s", dto.Symbol)) + } + rate, err := decimal.NewFromString(dto.Rate) + if err != nil { + return newBadRequest(fmt.Sprintf("invalid rate: %s", dto.Rate)) + } + items = append(items, portfolio.UpdatePortfolioItemInput{Symbol: sym, Rate: rate}) + } + + err := c.updatePortfolioUsecase.Run(portfolio.UpdatePortfolioUsecaseInput{Items: items}) + if err != nil { + if invalidErr, ok := portfolio.AsInvalidPortfolioError(err); ok { + return newBadRequest(invalidErr.Reason) + } + return err + } + return nil +} diff --git a/golang/internal/presentation/preparation.go b/golang/internal/presentation/preparation.go new file mode 100644 index 0000000..c4b3845 --- /dev/null +++ b/golang/internal/presentation/preparation.go @@ -0,0 +1,25 @@ +package presentation + +import ( + "fmt" + + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +func parseUserId(s string) (domain.UserId, error) { + uid, err := domain.NewUserId(s) + if err != nil { + return domain.UserId{}, newBadRequest(err.Error()) + } + return uid, nil +} + +func parseAmount(s string) (decimal.Decimal, error) { + d, err := decimal.NewFromString(s) + if err != nil { + return decimal.Zero, newBadRequest(fmt.Sprintf("invalid amount: %s", s)) + } + return d, nil +} diff --git a/golang/test/optimal_portfolio_scenario_test.go b/golang/test/optimal_portfolio_scenario_test.go new file mode 100644 index 0000000..72da38a --- /dev/null +++ b/golang/test/optimal_portfolio_scenario_test.go @@ -0,0 +1,78 @@ +package test + +import ( + "testing" + + "folio/codinginterview/internal/infrastructure/server" + "folio/codinginterview/internal/presentation" + + "github.com/shopspring/decimal" +) + +func TestOptimalPortfolioScenario(t *testing.T) { + s := server.NewDefaultDummyServer() + pc := s.PortfolioController + + t.Log("最適ポートフォリオを Toyopa=0.20, Somy=0.80 に更新する") + err := pc.UpdateOptimalPortfolio(presentation.UpdateOptimalPortfolioRequest{ + Portfolios: []presentation.PortfolioItemDto{ + {Symbol: "Toyopa", Rate: "0.20"}, + {Symbol: "Somy", Rate: "0.80"}, + }, + }) + if err != nil { + t.Fatalf("UpdateOptimalPortfolio failed: %v", err) + } + + t.Log("最適ポートフォリオを取得する") + first, err := pc.GetOptimalPortfolio() + if err != nil { + t.Fatalf("GetOptimalPortfolio failed: %v", err) + } + firstMap := make(map[string]string) + for _, p := range first.Portfolios { + firstMap[p.Symbol] = p.Rate + } + + t.Log("Toyopa=0.20, Somy=0.80 が返される") + assertDecimalEqual(t, "Toyopa rate", firstMap["Toyopa"], "0.20") + assertDecimalEqual(t, "Somy rate", firstMap["Somy"], "0.80") + + t.Log("最適ポートフォリオを Toyopa=0.40, Somy=0.60 に更新して再取得する") + err = pc.UpdateOptimalPortfolio(presentation.UpdateOptimalPortfolioRequest{ + Portfolios: []presentation.PortfolioItemDto{ + {Symbol: "Toyopa", Rate: "0.40"}, + {Symbol: "Somy", Rate: "0.60"}, + }, + }) + if err != nil { + t.Fatalf("UpdateOptimalPortfolio failed: %v", err) + } + second, err := pc.GetOptimalPortfolio() + if err != nil { + t.Fatalf("GetOptimalPortfolio failed: %v", err) + } + secondMap := make(map[string]string) + for _, p := range second.Portfolios { + secondMap[p.Symbol] = p.Rate + } + + t.Log("Toyopa=0.40, Somy=0.60 が返される") + assertDecimalEqual(t, "Toyopa rate", secondMap["Toyopa"], "0.40") + assertDecimalEqual(t, "Somy rate", secondMap["Somy"], "0.60") +} + +func assertDecimalEqual(t *testing.T, label, got, want string) { + t.Helper() + g, err := decimal.NewFromString(got) + if err != nil { + t.Fatalf("%s: invalid got value %q: %v", label, got, err) + } + w, err := decimal.NewFromString(want) + if err != nil { + t.Fatalf("%s: invalid want value %q: %v", label, want, err) + } + if !g.Equal(w) { + t.Errorf("%s: got %s, want %s", label, got, want) + } +} diff --git a/golang/test/order_scenario_test.go b/golang/test/order_scenario_test.go new file mode 100644 index 0000000..f1afb07 --- /dev/null +++ b/golang/test/order_scenario_test.go @@ -0,0 +1,176 @@ +package test + +import ( + "fmt" + "testing" + + "folio/codinginterview/internal/infrastructure/server" + "folio/codinginterview/internal/presentation" + + "github.com/shopspring/decimal" +) + +func TestOrderScenario(t *testing.T) { + s := server.NewDefaultDummyServer() + ac := s.AssetController + pc := s.PortfolioController + oc := s.OrderController + mp := s.MarketPriceController + + // initialize market price and optimal portfolio + err := pc.UpdateOptimalPortfolio(presentation.UpdateOptimalPortfolioRequest{ + Portfolios: []presentation.PortfolioItemDto{ + {Symbol: "Toyopa", Rate: "0.40"}, + {Symbol: "Somy", Rate: "0.60"}, + }, + }) + if err != nil { + t.Fatalf("setup UpdateOptimalPortfolio failed: %v", err) + } + err = mp.UpdateMarketPrice(presentation.UpdateMarketPriceRequest{ + MarketPrices: []presentation.MarketPriceItemDto{ + {Symbol: "Toyopa", MarketPrice: "2.5"}, + {Symbol: "Somy", MarketPrice: "3.0"}, + }, + }) + if err != nil { + t.Fatalf("setup UpdateMarketPrice failed: %v", err) + } + + userId := fmt.Sprintf("test-user-%d", 1) + + t.Log("存在しないユーザーで資産を取得しようとする") + _, err = ac.GetAsset(presentation.GetAssetRequest{UserId: userId}) + if _, ok := presentation.IsBadRequestError(err); !ok { + t.Fatalf("GetAsset should return BadRequestError for unknown user, got: %v", err) + } + t.Log("BadRequestException が返される") + + t.Log("最適ポートフォリオを Toyopa=40%, Somy=60% に更新する") + err = pc.UpdateOptimalPortfolio(presentation.UpdateOptimalPortfolioRequest{ + Portfolios: []presentation.PortfolioItemDto{ + {Symbol: "Toyopa", Rate: "0.40"}, + {Symbol: "Somy", Rate: "0.60"}, + }, + }) + if err != nil { + t.Fatalf("UpdateOptimalPortfolio failed: %v", err) + } + + t.Log("新規拠出を 100,000 円で注文する") + err = oc.NewContributionOrder(presentation.NewContributionOrderRequest{UserId: userId, Amount: "100000"}) + if err != nil { + t.Fatalf("NewContributionOrder failed: %v", err) + } + + t.Log("現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる") + // investable = 100000 - floor0(100000 * 0.05) = 95000 + asset1, err := ac.GetAsset(presentation.GetAssetRequest{UserId: userId}) + if err != nil { + t.Fatalf("GetAsset failed: %v", err) + } + symbols1 := make(map[string]struct{}) + for _, s := range asset1.Stocks { + symbols1[s.Symbol] = struct{}{} + } + if _, ok := symbols1["Toyopa"]; !ok { + t.Error("asset1 should contain Toyopa") + } + if _, ok := symbols1["Somy"]; !ok { + t.Error("asset1 should contain Somy") + } + total1 := mustDecimal(asset1.CashAmount) + for _, s := range asset1.Stocks { + total1 = total1.Add(mustDecimal(s.EvaluationAmount)) + } + diff1 := total1.Sub(decimal.NewFromInt(100000)).Abs() + if diff1.GreaterThan(decimal.NewFromInt(2)) { + t.Errorf("asset1 total should be ~100000, got %s", total1.String()) + } + + asset1Toyopa := findStock(asset1.Stocks, "Toyopa") + asset1Somy := findStock(asset1.Stocks, "Somy") + assertDecimalEqual(t, "asset1 Toyopa evaluation", asset1Toyopa.EvaluationAmount, "38000") // floor2(95000 * 0.40 / 2.5) = 15200株 * 2.5 + assertDecimalEqual(t, "asset1 Somy evaluation", asset1Somy.EvaluationAmount, "57000") // floor2(95000 * 0.60 / 3.0) = 19000株 * 3.0 + assertDecimalEqual(t, "asset1 cash", asset1.CashAmount, "5000") // 100000 - 38000 - 57000 + + t.Log("追加拠出を 100,000 円で注文する") + err = oc.AdditionalContributionOrder(presentation.AdditionalContributionOrderRequest{UserId: userId, Amount: "100000"}) + if err != nil { + t.Fatalf("AdditionalContributionOrder failed: %v", err) + } + + t.Log("資産合計が約 200,000 円になる") + asset2, err := ac.GetAsset(presentation.GetAssetRequest{UserId: userId}) + if err != nil { + t.Fatalf("GetAsset failed: %v", err) + } + total2 := mustDecimal(asset2.CashAmount) + for _, s := range asset2.Stocks { + total2 = total2.Add(mustDecimal(s.EvaluationAmount)) + } + diff2 := total2.Sub(decimal.NewFromInt(200000)).Abs() + if diff2.GreaterThan(decimal.NewFromInt(4)) { + t.Errorf("asset2 total should be ~200000, got %s", total2.String()) + } + + t.Log("現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる") + asset2Toyopa := findStock(asset2.Stocks, "Toyopa") + asset2Somy := findStock(asset2.Stocks, "Somy") + assertDecimalEqual(t, "asset2 Toyopa evaluation", asset2Toyopa.EvaluationAmount, "76000") // floor2(190000 * 0.40 / 2.5) = 30400株 * 2.5 + assertDecimalEqual(t, "asset2 Somy evaluation", asset2Somy.EvaluationAmount, "114000") // floor2(190000 * 0.60 / 3.0) = 38000株 * 3.0 + assertDecimalEqual(t, "asset2 cash", asset2.CashAmount, "10000") // 200000 - 76000 - 114000 + + t.Log("最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする") + err = pc.UpdateOptimalPortfolio(presentation.UpdateOptimalPortfolioRequest{ + Portfolios: []presentation.PortfolioItemDto{ + {Symbol: "Toyopa", Rate: "0.10"}, + {Symbol: "Somy", Rate: "0.90"}, + }, + }) + if err != nil { + t.Fatalf("UpdateOptimalPortfolio failed: %v", err) + } + err = oc.RebalanceOrder(presentation.RebalanceOrderRequest{UserId: userId}) + if err != nil { + t.Fatalf("RebalanceOrder failed: %v", err) + } + + t.Log("リバランス後も資産合計がほぼ変わらない") + asset3, err := ac.GetAsset(presentation.GetAssetRequest{UserId: userId}) + if err != nil { + t.Fatalf("GetAsset failed: %v", err) + } + total3 := mustDecimal(asset3.CashAmount) + for _, s := range asset3.Stocks { + total3 = total3.Add(mustDecimal(s.EvaluationAmount)) + } + diff3 := total3.Sub(total2).Abs() + if diff3.GreaterThan(decimal.NewFromInt(4)) { + t.Errorf("asset3 total should be ~%s (total2), got %s", total2.String(), total3.String()) + } + + t.Log("現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる") + asset3Toyopa := findStock(asset3.Stocks, "Toyopa") + asset3Somy := findStock(asset3.Stocks, "Somy") + assertDecimalEqual(t, "asset3 Toyopa evaluation", asset3Toyopa.EvaluationAmount, "19000") // floor2(190000 * 0.10 / 2.5) = 7600株 * 2.5 + assertDecimalEqual(t, "asset3 Somy evaluation", asset3Somy.EvaluationAmount, "171000") // floor2(190000 * 0.90 / 3.0) = 57000株 * 3.0 + assertDecimalEqual(t, "asset3 cash", asset3.CashAmount, "10000") // 200000 - 19000 - 171000 +} + +func findStock(stocks []presentation.StockDto, symbol string) presentation.StockDto { + for _, s := range stocks { + if s.Symbol == symbol { + return s + } + } + return presentation.StockDto{} +} + +func mustDecimal(s string) decimal.Decimal { + d, err := decimal.NewFromString(s) + if err != nil { + panic(fmt.Sprintf("invalid decimal %q: %v", s, err)) + } + return d +} diff --git a/java17/.gitignore b/java17/.gitignore new file mode 100644 index 0000000..fa3306b --- /dev/null +++ b/java17/.gitignore @@ -0,0 +1,30 @@ +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/4d602a24bc5e4bbc2b8cedf08d4e982a80a7dfea/Java.gitignore + +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +target/ +.idea/ diff --git a/java17/.mvn/wrapper/maven-wrapper.properties b/java17/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..8dea6c2 --- /dev/null +++ b/java17/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/java17/README.md b/java17/README.md new file mode 100644 index 0000000..4b878d6 --- /dev/null +++ b/java17/README.md @@ -0,0 +1,67 @@ +# サンプルラップサービス + +## 開発 + +- Java 17+ + +```shell +# 準備 +git init && git add . && git commit -m init + +# セットアップ +./mvnw test-compile + +# テスト実行 +./mvnw test +``` + +## サービス概要 + +このアプリケーションはロボアドバイザーサービスのバックエンドです。 + +### 株と評価額 + +- 株には株数(qty)があります(例: 1株、2株) +- 各株には1株あたりの市場価格があります(例: 1株あたり100円) +- 例: 顧客が5株保有している場合、評価額は `5株 × 100円 = 500円` となります + +### ロボアドバイザーサービス + +- **顧客の口座** + - 新規拠出を行うと、口座がすぐに開きます + - 口座の中で資産を管理することになります +- **顧客の資産** + - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します + - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円をいくつかの株で保有する + - 株は価格で保持するのではなく、株数で保持します + - そのため、市場価格に応じて評価額は変わることになります +- **最適ポートフォリオ** + - サービスが管理する、株の評価額ベースの構成比率 + - 例: A株を評価額の30%B株を評価額の70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30% B株95万円*70% になるように努める + - 購入時・売却時・リバランス時には、売買後の資産比率が現在の最適ポートフォリオに近づく形での売買を実施します +- **株の売買** + - 本アプリケーションでは、注文APIを叩くと即時必要な株の売買が成立し資産に反映出来るものとします +- 用語 + - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 + - 全売却注文: 運用中の株を全て売却すること。 + - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + +## 確認観点 + +- 成果を出すこと +- 成果物についての理解・責任を持つこと + +## 課題 + +以下の課題をAIを用いて、あなた自身の言葉・実装で回答してください。 + +1. API/アーキテクチャについてAIと協力しながら自分の言葉で説明してください +2. テストが全て通るようにしてください + - まずは現状のテストを走らせて、どのような内容で落ちているかを説明してください + - また、実装バグがあるため、テストコードは一切変更せず、AIと協力しながら実装を修正してください + - その上で、修正内容について説明をしてください +3. 全売却APIを実装してください + - 全売却後の現金の取り扱いに関しての方針を決めてください + - ストレッチ: 売却後の現金は銀行APIへ連携 +4. (部分売却APIを実装してください) diff --git a/java17/mvnw b/java17/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/java17/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/java17/mvnw.cmd b/java17/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/java17/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/java17/pom.xml b/java17/pom.xml new file mode 100644 index 0000000..0c56b7a --- /dev/null +++ b/java17/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + folio + coding-interview + 0.1.0-SNAPSHOT + jar + + + 17 + 17 + UTF-8 + 5.10.2 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + diff --git a/java17/src/main/java/folio/codinginterview/application/repository/AccountRepository.java b/java17/src/main/java/folio/codinginterview/application/repository/AccountRepository.java new file mode 100644 index 0000000..6d1c382 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/repository/AccountRepository.java @@ -0,0 +1,18 @@ +package folio.codinginterview.application.repository; + +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.UserId; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +/** + * 口座管理リポジトリ。 + */ +public interface AccountRepository { + CompletableFuture> find(UserId userId); + + CompletableFuture upsert(UserId userId, Account account); + + CompletableFuture exists(UserId userId); +} diff --git a/java17/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java b/java17/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java new file mode 100644 index 0000000..499c187 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java @@ -0,0 +1,16 @@ +package folio.codinginterview.application.repository; + +import folio.codinginterview.domain.StockSymbol; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * 市場価格リポジトリ。 + */ +public interface MarketPriceRepository { + CompletableFuture> all(); + + CompletableFuture update(Map prices); +} diff --git a/java17/src/main/java/folio/codinginterview/application/repository/PortfolioRepository.java b/java17/src/main/java/folio/codinginterview/application/repository/PortfolioRepository.java new file mode 100644 index 0000000..f4263e5 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/repository/PortfolioRepository.java @@ -0,0 +1,14 @@ +package folio.codinginterview.application.repository; + +import folio.codinginterview.domain.Portfolio; + +import java.util.concurrent.CompletableFuture; + +/** + * 最適ポートフォリオリポジトリ。 + */ +public interface PortfolioRepository { + CompletableFuture get(); + + CompletableFuture update(Portfolio portfolio); +} diff --git a/java17/src/main/java/folio/codinginterview/application/service/AssetService.java b/java17/src/main/java/folio/codinginterview/application/service/AssetService.java new file mode 100644 index 0000000..89b0f32 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/service/AssetService.java @@ -0,0 +1,28 @@ +package folio.codinginterview.application.service; + +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.Stock; +import folio.codinginterview.domain.StockSymbol; + +import java.math.BigDecimal; +import java.util.Map; + +public final class AssetService { + private AssetService() {} + + public static BigDecimal evaluateStock(Stock stock, Map prices) { + BigDecimal price = prices.get(stock.symbol()); + if (price == null) { + throw new IllegalStateException("missing price for " + stock.symbol()); + } + return stock.qty().multiply(price); + } + + public static BigDecimal totalValuation(Account account, Map prices) { + BigDecimal sum = BigDecimal.ZERO; + for (Stock e : account.stocks()) { + sum = sum.add(evaluateStock(e, prices)); + } + return sum.add(account.cash()); + } +} diff --git a/java17/src/main/java/folio/codinginterview/application/service/PortfolioService.java b/java17/src/main/java/folio/codinginterview/application/service/PortfolioService.java new file mode 100644 index 0000000..474f0c5 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/service/PortfolioService.java @@ -0,0 +1,130 @@ +package folio.codinginterview.application.service; + +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.AppConstants; +import folio.codinginterview.domain.Stock; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.domain.Portfolio; +import folio.codinginterview.domain.PortfolioItem; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class PortfolioService { + private PortfolioService() {} + + private static BigDecimal floor2(BigDecimal x) { + return x.setScale(2, RoundingMode.DOWN); + } + + private static BigDecimal floor0(BigDecimal x) { + return x.setScale(0, RoundingMode.DOWN); + } + + private static BigDecimal priceOf(Map prices, StockSymbol symbol) { + BigDecimal p = prices.get(symbol); + if (p == null) { + throw new IllegalStateException("missing price for " + symbol); + } + return p; + } + + /** Allocate a brand-new account given a contribution amount. */ + public static Account allocateNew( + BigDecimal amount, + Portfolio portfolio, + Map prices + ) { + BigDecimal cashFromRate = floor0(amount.multiply(AppConstants.CASH_RATE)); + BigDecimal investable = amount.subtract(cashFromRate); + List stocks = new ArrayList<>(); + for (PortfolioItem item : portfolio.items()) { + BigDecimal price = priceOf(prices, item.symbol()); + BigDecimal qty = floor2(investable.multiply(item.rate()).divide(price, 20, RoundingMode.DOWN)); + stocks.add(new Stock(item.symbol(), qty)); + } + BigDecimal usedForStocks = BigDecimal.ZERO; + for (Stock e : stocks) { + usedForStocks = usedForStocks.add(e.qty().multiply(priceOf(prices, e.symbol()))); + } + BigDecimal residual = investable.subtract(usedForStocks); + return new Account(cashFromRate.add(residual), stocks); + } + + /** Additional contribution: only buy (no sell). Residual is kept in cash. */ + public static Account allocateAdditional( + Account account, + BigDecimal amount, + Portfolio portfolio, + Map prices + ) { + BigDecimal totalAfter = AssetService.totalValuation(account, prices).add(amount); + BigDecimal targetCash = floor0(totalAfter.multiply(AppConstants.CASH_RATE)); + BigDecimal investable = totalAfter.subtract(targetCash); + + Map currentQty = new HashMap<>(); + for (Stock e : account.stocks()) { + currentQty.put(e.symbol(), e.qty()); + } + + Set portfolioSymbols = new HashSet<>(); + for (PortfolioItem item : portfolio.items()) { + portfolioSymbols.add(item.symbol()); + } + + List newPortfolioStocks = new ArrayList<>(); + for (PortfolioItem item : portfolio.items()) { + BigDecimal price = priceOf(prices, item.symbol()); + BigDecimal targetQty = floor2(investable.multiply(item.rate()).divide(price, 20, RoundingMode.DOWN)); + BigDecimal current = currentQty.getOrDefault(item.symbol(), BigDecimal.ZERO); + BigDecimal finalQty = targetQty.compareTo(current) > 0 ? targetQty : current; + newPortfolioStocks.add(new Stock(item.symbol(), finalQty)); + } + + List preservedStocks = new ArrayList<>(); + for (Stock e : account.stocks()) { + if (!portfolioSymbols.contains(e.symbol())) { + preservedStocks.add(e); + } + } + + List allStocks = new ArrayList<>(); + allStocks.addAll(newPortfolioStocks); + allStocks.addAll(preservedStocks); + + BigDecimal finalValuation = BigDecimal.ZERO; + for (Stock e : allStocks) { + finalValuation = finalValuation.add(e.qty().multiply(priceOf(prices, e.symbol()))); + } + BigDecimal finalCash = totalAfter.subtract(finalValuation); + return new Account(finalCash, allStocks); + } + + /** Rebalance: re-allocate qty per portfolio target (buy and sell). */ + public static Account rebalance( + Account account, + Portfolio portfolio, + Map prices + ) { + // XXX this implementation might not be correct + BigDecimal investable = AssetService.totalValuation(account, prices); + List newStocks = new ArrayList<>(); + for (PortfolioItem item : portfolio.items()) { + BigDecimal price = priceOf(prices, item.symbol()); + BigDecimal qty = floor2(investable.multiply(item.rate()).divide(price, 20, RoundingMode.DOWN)); + newStocks.add(new Stock(item.symbol(), qty)); + } + BigDecimal finalValuation = BigDecimal.ZERO; + for (Stock e : newStocks) { + finalValuation = finalValuation.add(e.qty().multiply(priceOf(prices, e.symbol()))); + } + BigDecimal finalCash = investable.subtract(finalValuation); + return new Account(finalCash, newStocks); + } +} diff --git a/java17/src/main/java/folio/codinginterview/application/usecase/asset/GetAssetUsecase.java b/java17/src/main/java/folio/codinginterview/application/usecase/asset/GetAssetUsecase.java new file mode 100644 index 0000000..83eb41a --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/usecase/asset/GetAssetUsecase.java @@ -0,0 +1,56 @@ +package folio.codinginterview.application.usecase.asset; + +import folio.codinginterview.application.repository.AccountRepository; +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.application.service.AssetService; +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.domain.UserId; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public final class GetAssetUsecase { + public record Input(UserId userId) {} + + public record StockOutput(StockSymbol symbol, BigDecimal evaluationAmount) {} + + public record Output(BigDecimal cashAmount, List stocks) {} + + public static class Exception extends RuntimeException { + protected Exception(String msg) { super(msg); } + } + + public static final class UserNotFound extends Exception { + public static final UserNotFound INSTANCE = new UserNotFound(); + private UserNotFound() { super("user not found"); } + } + + private final AccountRepository accountRepository; + private final MarketPriceRepository marketPriceRepository; + + public GetAssetUsecase(AccountRepository accountRepository, MarketPriceRepository marketPriceRepository) { + this.accountRepository = accountRepository; + this.marketPriceRepository = marketPriceRepository; + } + + public CompletableFuture run(Input input) { + return accountRepository.find(input.userId()).thenCompose(maybeAccount -> { + if (maybeAccount.isEmpty()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(UserNotFound.INSTANCE); + return failed; + } + Account account = maybeAccount.get(); + return marketPriceRepository.all().thenApply(prices -> { + List stocks = new ArrayList<>(); + for (var e : account.stocks()) { + stocks.add(new StockOutput(e.symbol(), AssetService.evaluateStock(e, prices))); + } + return new Output(account.cash(), stocks); + }); + }); + } +} diff --git a/java17/src/main/java/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.java b/java17/src/main/java/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.java new file mode 100644 index 0000000..eaf1e3a --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.java @@ -0,0 +1,30 @@ +package folio.codinginterview.application.usecase.market_price; + +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.domain.StockSymbol; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public final class UpdateMarketPriceUsecase { + public record ItemInput(StockSymbol symbol, BigDecimal marketPrice) {} + + public record Input(List items) {} + + private final MarketPriceRepository marketPriceRepository; + + public UpdateMarketPriceUsecase(MarketPriceRepository marketPriceRepository) { + this.marketPriceRepository = marketPriceRepository; + } + + public CompletableFuture run(Input input) { + Map prices = new LinkedHashMap<>(); + for (ItemInput i : input.items()) { + prices.put(i.symbol(), i.marketPrice()); + } + return marketPriceRepository.update(prices); + } +} diff --git a/java17/src/main/java/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.java b/java17/src/main/java/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.java new file mode 100644 index 0000000..cc72801 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.java @@ -0,0 +1,64 @@ +package folio.codinginterview.application.usecase.order; + +import folio.codinginterview.application.repository.AccountRepository; +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.application.service.PortfolioService; +import folio.codinginterview.domain.AppConstants; +import folio.codinginterview.domain.UserId; + +import java.math.BigDecimal; +import java.util.concurrent.CompletableFuture; + +public final class AdditionalBuyOrderUsecase { + public record Input(UserId userId, BigDecimal amount) {} + + public static class Exception extends RuntimeException { + protected Exception(String msg) { super(msg); } + } + + public static final class UserNotFound extends Exception { + public static final UserNotFound INSTANCE = new UserNotFound(); + private UserNotFound() { super("user not found"); } + } + + public static final class AmountTooSmall extends Exception { + public static final AmountTooSmall INSTANCE = new AmountTooSmall(); + private AmountTooSmall() { super("amount too small"); } + } + + private final AccountRepository accountRepository; + private final PortfolioRepository portfolioRepository; + private final MarketPriceRepository marketPriceRepository; + + public AdditionalBuyOrderUsecase( + AccountRepository accountRepository, + PortfolioRepository portfolioRepository, + MarketPriceRepository marketPriceRepository + ) { + this.accountRepository = accountRepository; + this.portfolioRepository = portfolioRepository; + this.marketPriceRepository = marketPriceRepository; + } + + public CompletableFuture run(Input input) { + if (input.amount().compareTo(AppConstants.MIN_OPERATION_AMOUNT) < 0) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(AmountTooSmall.INSTANCE); + return failed; + } + return accountRepository.find(input.userId()).thenCompose(maybeAccount -> { + if (maybeAccount.isEmpty()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(UserNotFound.INSTANCE); + return failed; + } + var account = maybeAccount.get(); + return portfolioRepository.get().thenCompose(portfolio -> + marketPriceRepository.all().thenCompose(prices -> { + var updated = PortfolioService.allocateAdditional(account, input.amount(), portfolio, prices); + return accountRepository.upsert(input.userId(), updated); + })); + }); + } +} diff --git a/java17/src/main/java/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.java b/java17/src/main/java/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.java new file mode 100644 index 0000000..6e84bae --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.java @@ -0,0 +1,63 @@ +package folio.codinginterview.application.usecase.order; + +import folio.codinginterview.application.repository.AccountRepository; +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.application.service.PortfolioService; +import folio.codinginterview.domain.AppConstants; +import folio.codinginterview.domain.UserId; + +import java.math.BigDecimal; +import java.util.concurrent.CompletableFuture; + +public final class NewContributionOrderUsecase { + public record Input(UserId userId, BigDecimal amount) {} + + public static class Exception extends RuntimeException { + protected Exception(String msg) { super(msg); } + } + + public static final class UserAlreadyExists extends Exception { + public static final UserAlreadyExists INSTANCE = new UserAlreadyExists(); + private UserAlreadyExists() { super("user already exists"); } + } + + public static final class AmountTooSmall extends Exception { + public static final AmountTooSmall INSTANCE = new AmountTooSmall(); + private AmountTooSmall() { super("amount too small"); } + } + + private final AccountRepository accountRepository; + private final PortfolioRepository portfolioRepository; + private final MarketPriceRepository marketPriceRepository; + + public NewContributionOrderUsecase( + AccountRepository accountRepository, + PortfolioRepository portfolioRepository, + MarketPriceRepository marketPriceRepository + ) { + this.accountRepository = accountRepository; + this.portfolioRepository = portfolioRepository; + this.marketPriceRepository = marketPriceRepository; + } + + public CompletableFuture run(Input input) { + if (input.amount().compareTo(AppConstants.MIN_OPERATION_AMOUNT) < 0) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(AmountTooSmall.INSTANCE); + return failed; + } + return accountRepository.exists(input.userId()).thenCompose(exists -> { + if (exists) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(UserAlreadyExists.INSTANCE); + return failed; + } + return portfolioRepository.get().thenCompose(portfolio -> + marketPriceRepository.all().thenCompose(prices -> { + var account = PortfolioService.allocateNew(input.amount(), portfolio, prices); + return accountRepository.upsert(input.userId(), account); + })); + }); + } +} diff --git a/java17/src/main/java/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.java b/java17/src/main/java/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.java new file mode 100644 index 0000000..13048af --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.java @@ -0,0 +1,52 @@ +package folio.codinginterview.application.usecase.order; + +import folio.codinginterview.application.repository.AccountRepository; +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.application.service.PortfolioService; +import folio.codinginterview.domain.UserId; + +import java.util.concurrent.CompletableFuture; + +public final class RebalanceOrderUsecase { + public record Input(UserId userId) {} + + public static class Exception extends RuntimeException { + protected Exception(String msg) { super(msg); } + } + + public static final class UserNotFound extends Exception { + public static final UserNotFound INSTANCE = new UserNotFound(); + private UserNotFound() { super("user not found"); } + } + + private final AccountRepository accountRepository; + private final PortfolioRepository portfolioRepository; + private final MarketPriceRepository marketPriceRepository; + + public RebalanceOrderUsecase( + AccountRepository accountRepository, + PortfolioRepository portfolioRepository, + MarketPriceRepository marketPriceRepository + ) { + this.accountRepository = accountRepository; + this.portfolioRepository = portfolioRepository; + this.marketPriceRepository = marketPriceRepository; + } + + public CompletableFuture run(Input input) { + return accountRepository.find(input.userId()).thenCompose(maybeAccount -> { + if (maybeAccount.isEmpty()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(UserNotFound.INSTANCE); + return failed; + } + var account = maybeAccount.get(); + return portfolioRepository.get().thenCompose(portfolio -> + marketPriceRepository.all().thenCompose(prices -> { + var updated = PortfolioService.rebalance(account, portfolio, prices); + return accountRepository.upsert(input.userId(), updated); + })); + }); + } +} diff --git a/java17/src/main/java/folio/codinginterview/application/usecase/portfolio/GetLatestPortfolioUsecase.java b/java17/src/main/java/folio/codinginterview/application/usecase/portfolio/GetLatestPortfolioUsecase.java new file mode 100644 index 0000000..f76a080 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/usecase/portfolio/GetLatestPortfolioUsecase.java @@ -0,0 +1,31 @@ +package folio.codinginterview.application.usecase.portfolio; + +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.domain.StockSymbol; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public final class GetLatestPortfolioUsecase { + public record ItemOutput(StockSymbol symbol, BigDecimal rate) {} + + public record Output(List items) {} + + private final PortfolioRepository portfolioRepository; + + public GetLatestPortfolioUsecase(PortfolioRepository portfolioRepository) { + this.portfolioRepository = portfolioRepository; + } + + public CompletableFuture run() { + return portfolioRepository.get().thenApply(p -> { + List items = new ArrayList<>(); + for (var i : p.items()) { + items.add(new ItemOutput(i.symbol(), i.rate())); + } + return new Output(items); + }); + } +} diff --git a/java17/src/main/java/folio/codinginterview/application/usecase/portfolio/UpdatePortfolioUsecase.java b/java17/src/main/java/folio/codinginterview/application/usecase/portfolio/UpdatePortfolioUsecase.java new file mode 100644 index 0000000..5e09266 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/application/usecase/portfolio/UpdatePortfolioUsecase.java @@ -0,0 +1,47 @@ +package folio.codinginterview.application.usecase.portfolio; + +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.domain.Portfolio; +import folio.codinginterview.domain.PortfolioItem; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public final class UpdatePortfolioUsecase { + public record ItemInput(StockSymbol symbol, BigDecimal rate) {} + + public record Input(List items) {} + + public static class Exception extends RuntimeException { + protected Exception(String msg) { super(msg); } + } + + public static final class InvalidPortfolio extends Exception { + public InvalidPortfolio(String reason) { super(reason); } + } + + private final PortfolioRepository portfolioRepository; + + public UpdatePortfolioUsecase(PortfolioRepository portfolioRepository) { + this.portfolioRepository = portfolioRepository; + } + + public CompletableFuture run(Input input) { + Portfolio portfolio; + try { + List items = new ArrayList<>(); + for (var i : input.items()) { + items.add(new PortfolioItem(i.symbol(), i.rate())); + } + portfolio = new Portfolio(items); + } catch (RuntimeException e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new InvalidPortfolio(e.getMessage())); + return failed; + } + return portfolioRepository.update(portfolio); + } +} diff --git a/java17/src/main/java/folio/codinginterview/domain/Account.java b/java17/src/main/java/folio/codinginterview/domain/Account.java new file mode 100644 index 0000000..ed574a2 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/domain/Account.java @@ -0,0 +1,10 @@ +package folio.codinginterview.domain; + +import java.math.BigDecimal; +import java.util.List; + +public record Account(BigDecimal cash, List stocks) { + public Account { + stocks = List.copyOf(stocks); + } +} diff --git a/java17/src/main/java/folio/codinginterview/domain/AppConstants.java b/java17/src/main/java/folio/codinginterview/domain/AppConstants.java new file mode 100644 index 0000000..372b1e4 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/domain/AppConstants.java @@ -0,0 +1,30 @@ +package folio.codinginterview.domain; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class AppConstants { + private AppConstants() {} + + public static final BigDecimal CASH_RATE = new BigDecimal("0.05"); + + public static final BigDecimal MIN_OPERATION_AMOUNT = new BigDecimal(10000); + + public static final List SUPPORTED_SYMBOLS = List.of(StockSymbol.Toyopa, StockSymbol.Somy); + + public static final Map INITIAL_PRICES; + + public static final Portfolio INITIAL_PORTFOLIO = new Portfolio(List.of( + new PortfolioItem(StockSymbol.Toyopa, new BigDecimal("0.40")), + new PortfolioItem(StockSymbol.Somy, new BigDecimal("0.60")) + )); + + static { + Map p = new LinkedHashMap<>(); + p.put(StockSymbol.Toyopa, new BigDecimal("4.2135")); + p.put(StockSymbol.Somy, new BigDecimal("1.2345")); + INITIAL_PRICES = Map.copyOf(p); + } +} diff --git a/java17/src/main/java/folio/codinginterview/domain/Portfolio.java b/java17/src/main/java/folio/codinginterview/domain/Portfolio.java new file mode 100644 index 0000000..ec4e13a --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/domain/Portfolio.java @@ -0,0 +1,29 @@ +package folio.codinginterview.domain; + +import java.math.BigDecimal; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public record Portfolio(List items) { + public Portfolio { + if (items == null || items.isEmpty()) { + throw new IllegalArgumentException("portfolio must have at least one item"); + } + BigDecimal sum = BigDecimal.ZERO; + for (PortfolioItem i : items) { + sum = sum.add(i.rate()); + } + if (sum.compareTo(BigDecimal.ONE) != 0) { + throw new IllegalArgumentException("portfolio rates must sum to 1, got " + sum); + } + Set symbols = new HashSet<>(); + for (PortfolioItem i : items) { + symbols.add(i.symbol()); + } + if (symbols.size() != items.size()) { + throw new IllegalArgumentException("portfolio must not have duplicate symbols"); + } + items = List.copyOf(items); + } +} diff --git a/java17/src/main/java/folio/codinginterview/domain/PortfolioItem.java b/java17/src/main/java/folio/codinginterview/domain/PortfolioItem.java new file mode 100644 index 0000000..e45c87e --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/domain/PortfolioItem.java @@ -0,0 +1,6 @@ +package folio.codinginterview.domain; + +import java.math.BigDecimal; + +public record PortfolioItem(StockSymbol symbol, BigDecimal rate) { +} diff --git a/java17/src/main/java/folio/codinginterview/domain/Stock.java b/java17/src/main/java/folio/codinginterview/domain/Stock.java new file mode 100644 index 0000000..78eb63b --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/domain/Stock.java @@ -0,0 +1,6 @@ +package folio.codinginterview.domain; + +import java.math.BigDecimal; + +public record Stock(StockSymbol symbol, BigDecimal qty) { +} diff --git a/java17/src/main/java/folio/codinginterview/domain/StockSymbol.java b/java17/src/main/java/folio/codinginterview/domain/StockSymbol.java new file mode 100644 index 0000000..7db6b29 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/domain/StockSymbol.java @@ -0,0 +1,15 @@ +package folio.codinginterview.domain; + +import java.util.Optional; + +public enum StockSymbol { + Toyopa, Somy; + + public static Optional fromString(String s) { + return switch (s) { + case "Toyopa" -> Optional.of(Toyopa); + case "Somy" -> Optional.of(Somy); + default -> Optional.empty(); + }; + } +} diff --git a/java17/src/main/java/folio/codinginterview/domain/UserId.java b/java17/src/main/java/folio/codinginterview/domain/UserId.java new file mode 100644 index 0000000..a41965c --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/domain/UserId.java @@ -0,0 +1,9 @@ +package folio.codinginterview.domain; + +public record UserId(String value) { + public UserId { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException("userId must not be empty"); + } + } +} diff --git a/java17/src/main/java/folio/codinginterview/infrastructure/repository/AccountRepositoryImpl.java b/java17/src/main/java/folio/codinginterview/infrastructure/repository/AccountRepositoryImpl.java new file mode 100644 index 0000000..74aecb2 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/infrastructure/repository/AccountRepositoryImpl.java @@ -0,0 +1,30 @@ +package folio.codinginterview.infrastructure.repository; + +import folio.codinginterview.application.repository.AccountRepository; +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.UserId; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public final class AccountRepositoryImpl implements AccountRepository { + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + @Override + public CompletableFuture> find(UserId userId) { + return CompletableFuture.completedFuture(Optional.ofNullable(store.get(userId.value()))); + } + + @Override + public CompletableFuture upsert(UserId userId, Account account) { + store.put(userId.value(), account); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture exists(UserId userId) { + return CompletableFuture.completedFuture(store.containsKey(userId.value())); + } +} diff --git a/java17/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java b/java17/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java new file mode 100644 index 0000000..5e05c17 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java @@ -0,0 +1,26 @@ +package folio.codinginterview.infrastructure.repository; + +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.domain.AppConstants; +import folio.codinginterview.domain.StockSymbol; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +public final class MarketPriceRepositoryImpl implements MarketPriceRepository { + private final AtomicReference> ref = + new AtomicReference<>(AppConstants.INITIAL_PRICES); + + @Override + public CompletableFuture> all() { + return CompletableFuture.completedFuture(ref.get()); + } + + @Override + public CompletableFuture update(Map prices) { + ref.set(Map.copyOf(prices)); + return CompletableFuture.completedFuture(null); + } +} diff --git a/java17/src/main/java/folio/codinginterview/infrastructure/repository/PortfolioRepositoryImpl.java b/java17/src/main/java/folio/codinginterview/infrastructure/repository/PortfolioRepositoryImpl.java new file mode 100644 index 0000000..cf57b25 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/infrastructure/repository/PortfolioRepositoryImpl.java @@ -0,0 +1,23 @@ +package folio.codinginterview.infrastructure.repository; + +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.domain.AppConstants; +import folio.codinginterview.domain.Portfolio; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +public final class PortfolioRepositoryImpl implements PortfolioRepository { + private final AtomicReference ref = new AtomicReference<>(AppConstants.INITIAL_PORTFOLIO); + + @Override + public CompletableFuture get() { + return CompletableFuture.completedFuture(ref.get()); + } + + @Override + public CompletableFuture update(Portfolio portfolio) { + ref.set(portfolio); + return CompletableFuture.completedFuture(null); + } +} diff --git a/java17/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java b/java17/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java new file mode 100644 index 0000000..4f07396 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java @@ -0,0 +1,68 @@ +package folio.codinginterview.infrastructure.server; + +import folio.codinginterview.application.usecase.asset.GetAssetUsecase; +import folio.codinginterview.application.usecase.market_price.UpdateMarketPriceUsecase; +import folio.codinginterview.application.usecase.order.AdditionalBuyOrderUsecase; +import folio.codinginterview.application.usecase.order.NewContributionOrderUsecase; +import folio.codinginterview.application.usecase.order.RebalanceOrderUsecase; +import folio.codinginterview.application.usecase.portfolio.GetLatestPortfolioUsecase; +import folio.codinginterview.application.usecase.portfolio.UpdatePortfolioUsecase; +import folio.codinginterview.infrastructure.repository.AccountRepositoryImpl; +import folio.codinginterview.infrastructure.repository.MarketPriceRepositoryImpl; +import folio.codinginterview.infrastructure.repository.PortfolioRepositoryImpl; +import folio.codinginterview.presentation.AssetController; +import folio.codinginterview.presentation.MarketPriceController; +import folio.codinginterview.presentation.OrderController; +import folio.codinginterview.presentation.PortfolioController; + +public final class DummyServer { + private final AssetController assetController; + private final PortfolioController portfolioController; + private final OrderController orderController; + private final MarketPriceController marketPriceController; + + public DummyServer( + AssetController assetController, + PortfolioController portfolioController, + OrderController orderController, + MarketPriceController marketPriceController + ) { + this.assetController = assetController; + this.portfolioController = portfolioController; + this.orderController = orderController; + this.marketPriceController = marketPriceController; + } + + public AssetController assetController() { return assetController; } + + public PortfolioController portfolioController() { return portfolioController; } + + public OrderController orderController() { return orderController; } + + public MarketPriceController marketPriceController() { return marketPriceController; } + + public static DummyServer defaultInstance() { + var portfolioRepository = new PortfolioRepositoryImpl(); + var accountRepository = new AccountRepositoryImpl(); + var marketPriceRepository = new MarketPriceRepositoryImpl(); + + var getAssetUsecase = new GetAssetUsecase(accountRepository, marketPriceRepository); + var getLatestPortfolioUsecase = new GetLatestPortfolioUsecase(portfolioRepository); + var updatePortfolioUsecase = new UpdatePortfolioUsecase(portfolioRepository); + var updateMarketPriceUsecase = new UpdateMarketPriceUsecase(marketPriceRepository); + var newContributionOrderUsecase = new NewContributionOrderUsecase( + accountRepository, portfolioRepository, marketPriceRepository); + var additionalBuyOrderUsecase = new AdditionalBuyOrderUsecase( + accountRepository, portfolioRepository, marketPriceRepository); + var rebalanceOrderUsecase = new RebalanceOrderUsecase( + accountRepository, portfolioRepository, marketPriceRepository); + + var assetController = new AssetController(getAssetUsecase); + var portfolioController = new PortfolioController(getLatestPortfolioUsecase, updatePortfolioUsecase); + var orderController = new OrderController( + newContributionOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase); + var marketPriceController = new MarketPriceController(updateMarketPriceUsecase); + + return new DummyServer(assetController, portfolioController, orderController, marketPriceController); + } +} diff --git a/java17/src/main/java/folio/codinginterview/infrastructure/server/Main.java b/java17/src/main/java/folio/codinginterview/infrastructure/server/Main.java new file mode 100644 index 0000000..bb20878 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/infrastructure/server/Main.java @@ -0,0 +1,8 @@ +package folio.codinginterview.infrastructure.server; + +public final class Main { + public static void main(String[] args) { + DummyServer.defaultInstance(); + System.out.println("DummyServer initialized."); + } +} diff --git a/java17/src/main/java/folio/codinginterview/presentation/AssetController.java b/java17/src/main/java/folio/codinginterview/presentation/AssetController.java new file mode 100644 index 0000000..a413d84 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/presentation/AssetController.java @@ -0,0 +1,42 @@ +package folio.codinginterview.presentation; + +import folio.codinginterview.application.usecase.asset.GetAssetUsecase; +import folio.codinginterview.presentation.PresentationException.BadRequestException; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +public final class AssetController extends PresentationPreparation { + public record StockDto(String symbol, String evaluationAmount) {} + + public record GetAssetRequest(String userId) {} + + public record GetAssetResponse(String cashAmount, List stocks) {} + + private final GetAssetUsecase getAssetUsecase; + + public AssetController(GetAssetUsecase getAssetUsecase) { + this.getAssetUsecase = getAssetUsecase; + } + + public CompletableFuture getAsset(GetAssetRequest req) { + return parseUserId(req.userId()).thenCompose(uid -> + getAssetUsecase.run(new GetAssetUsecase.Input(uid)) + .handle((out, ex) -> { + if (ex != null) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof GetAssetUsecase.UserNotFound) { + throw new CompletionException(new BadRequestException("user not found")); + } + throw new CompletionException(cause); + } + List stocks = new ArrayList<>(); + for (var e : out.stocks()) { + stocks.add(new StockDto(e.symbol().toString(), e.evaluationAmount().toString())); + } + return new GetAssetResponse(out.cashAmount().toString(), stocks); + })); + } +} diff --git a/java17/src/main/java/folio/codinginterview/presentation/MarketPriceController.java b/java17/src/main/java/folio/codinginterview/presentation/MarketPriceController.java new file mode 100644 index 0000000..3457407 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/presentation/MarketPriceController.java @@ -0,0 +1,45 @@ +package folio.codinginterview.presentation; + +import folio.codinginterview.application.usecase.market_price.UpdateMarketPriceUsecase; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.presentation.PresentationException.BadRequestException; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +public final class MarketPriceController { + public record MarketPriceItemDto(String symbol, String market_price) {} + + public record UpdateMarketPriceRequest(List market_prices) {} + + private final UpdateMarketPriceUsecase updateMarketPriceUsecase; + + public MarketPriceController(UpdateMarketPriceUsecase updateMarketPriceUsecase) { + this.updateMarketPriceUsecase = updateMarketPriceUsecase; + } + + public CompletableFuture updateMarketPrice(UpdateMarketPriceRequest req) { + List items = new ArrayList<>(); + for (var dto : req.market_prices()) { + Optional sym = StockSymbol.fromString(dto.symbol()); + if (sym.isEmpty()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException("unknown symbol: " + dto.symbol())); + return failed; + } + BigDecimal price; + try { + price = new BigDecimal(dto.market_price()); + } catch (RuntimeException e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException("invalid market_price: " + dto.market_price())); + return failed; + } + items.add(new UpdateMarketPriceUsecase.ItemInput(sym.get(), price)); + } + return updateMarketPriceUsecase.run(new UpdateMarketPriceUsecase.Input(items)); + } +} diff --git a/java17/src/main/java/folio/codinginterview/presentation/OrderController.java b/java17/src/main/java/folio/codinginterview/presentation/OrderController.java new file mode 100644 index 0000000..6fb1275 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/presentation/OrderController.java @@ -0,0 +1,84 @@ +package folio.codinginterview.presentation; + +import folio.codinginterview.application.usecase.order.AdditionalBuyOrderUsecase; +import folio.codinginterview.application.usecase.order.NewContributionOrderUsecase; +import folio.codinginterview.application.usecase.order.RebalanceOrderUsecase; +import folio.codinginterview.presentation.PresentationException.BadRequestException; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +public final class OrderController extends PresentationPreparation { + public record NewContributionOrderRequest(String userId, String amount) {} + + public record AdditionalContributionOrderRequest(String userId, String amount) {} + + public record RebalanceOrderRequest(String userId) {} + + private final NewContributionOrderUsecase newContributionOrderUsecase; + private final AdditionalBuyOrderUsecase additionalBuyOrderUsecase; + private final RebalanceOrderUsecase rebalanceOrderUsecase; + + public OrderController( + NewContributionOrderUsecase newContributionOrderUsecase, + AdditionalBuyOrderUsecase additionalBuyOrderUsecase, + RebalanceOrderUsecase rebalanceOrderUsecase + ) { + this.newContributionOrderUsecase = newContributionOrderUsecase; + this.additionalBuyOrderUsecase = additionalBuyOrderUsecase; + this.rebalanceOrderUsecase = rebalanceOrderUsecase; + } + + public CompletableFuture newContributionOrder(NewContributionOrderRequest req) { + return parseUserId(req.userId()).thenCompose(uid -> + parseAmount(req.amount()).thenCompose(amt -> + newContributionOrderUsecase.run(new NewContributionOrderUsecase.Input(uid, amt)) + .handle((v, ex) -> { + if (ex != null) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof NewContributionOrderUsecase.UserAlreadyExists) { + throw new CompletionException(new BadRequestException("user already has account")); + } + if (cause instanceof NewContributionOrderUsecase.AmountTooSmall) { + throw new CompletionException(new BadRequestException("amount is too small")); + } + throw new CompletionException(cause); + } + return null; + }))); + } + + public CompletableFuture additionalContributionOrder(AdditionalContributionOrderRequest req) { + return parseUserId(req.userId()).thenCompose(uid -> + parseAmount(req.amount()).thenCompose(amt -> + additionalBuyOrderUsecase.run(new AdditionalBuyOrderUsecase.Input(uid, amt)) + .handle((v, ex) -> { + if (ex != null) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof AdditionalBuyOrderUsecase.UserNotFound) { + throw new CompletionException(new BadRequestException("user has no live account")); + } + if (cause instanceof AdditionalBuyOrderUsecase.AmountTooSmall) { + throw new CompletionException(new BadRequestException("amount is too small")); + } + throw new CompletionException(cause); + } + return null; + }))); + } + + public CompletableFuture rebalanceOrder(RebalanceOrderRequest req) { + return parseUserId(req.userId()).thenCompose(uid -> + rebalanceOrderUsecase.run(new RebalanceOrderUsecase.Input(uid)) + .handle((v, ex) -> { + if (ex != null) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof RebalanceOrderUsecase.UserNotFound) { + throw new CompletionException(new BadRequestException("user has no live account")); + } + throw new CompletionException(cause); + } + return null; + })); + } +} diff --git a/java17/src/main/java/folio/codinginterview/presentation/PortfolioController.java b/java17/src/main/java/folio/codinginterview/presentation/PortfolioController.java new file mode 100644 index 0000000..9d26d33 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/presentation/PortfolioController.java @@ -0,0 +1,74 @@ +package folio.codinginterview.presentation; + +import folio.codinginterview.application.usecase.portfolio.GetLatestPortfolioUsecase; +import folio.codinginterview.application.usecase.portfolio.UpdatePortfolioUsecase; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.presentation.PresentationException.BadRequestException; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +public final class PortfolioController { + public record PortfolioItemDto(String symbol, String rate) {} + + public record GetOptimalPortfolioResponse(List portfolios) {} + + public record UpdateOptimalPortfolioRequest(List portfolios) {} + + private final GetLatestPortfolioUsecase getLatestPortfolioUsecase; + private final UpdatePortfolioUsecase updatePortfolioUsecase; + + public PortfolioController( + GetLatestPortfolioUsecase getLatestPortfolioUsecase, + UpdatePortfolioUsecase updatePortfolioUsecase + ) { + this.getLatestPortfolioUsecase = getLatestPortfolioUsecase; + this.updatePortfolioUsecase = updatePortfolioUsecase; + } + + public CompletableFuture getOptimalPortfolio() { + return getLatestPortfolioUsecase.run().thenApply(out -> { + List items = new ArrayList<>(); + for (var i : out.items()) { + items.add(new PortfolioItemDto(i.symbol().toString(), i.rate().toString())); + } + return new GetOptimalPortfolioResponse(items); + }); + } + + public CompletableFuture updateOptimalPortfolio(UpdateOptimalPortfolioRequest req) { + List items = new ArrayList<>(); + for (var dto : req.portfolios()) { + Optional sym = StockSymbol.fromString(dto.symbol()); + if (sym.isEmpty()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException("unknown symbol: " + dto.symbol())); + return failed; + } + BigDecimal rate; + try { + rate = new BigDecimal(dto.rate()); + } catch (RuntimeException e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException("invalid rate: " + dto.rate())); + return failed; + } + items.add(new UpdatePortfolioUsecase.ItemInput(sym.get(), rate)); + } + return updatePortfolioUsecase.run(new UpdatePortfolioUsecase.Input(items)) + .handle((v, ex) -> { + if (ex != null) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof UpdatePortfolioUsecase.InvalidPortfolio ip) { + throw new CompletionException(new BadRequestException(ip.getMessage())); + } + throw new CompletionException(cause); + } + return null; + }); + } +} diff --git a/java17/src/main/java/folio/codinginterview/presentation/PresentationException.java b/java17/src/main/java/folio/codinginterview/presentation/PresentationException.java new file mode 100644 index 0000000..0b3e8d2 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/presentation/PresentationException.java @@ -0,0 +1,14 @@ +package folio.codinginterview.presentation; + +public sealed class PresentationException extends RuntimeException + permits PresentationException.BadRequestException { + protected PresentationException(String message) { + super(message); + } + + public static final class BadRequestException extends PresentationException { + public BadRequestException(String message) { + super(message); + } + } +} diff --git a/java17/src/main/java/folio/codinginterview/presentation/PresentationPreparation.java b/java17/src/main/java/folio/codinginterview/presentation/PresentationPreparation.java new file mode 100644 index 0000000..41b4533 --- /dev/null +++ b/java17/src/main/java/folio/codinginterview/presentation/PresentationPreparation.java @@ -0,0 +1,29 @@ +package folio.codinginterview.presentation; + +import folio.codinginterview.domain.UserId; +import folio.codinginterview.presentation.PresentationException.BadRequestException; + +import java.math.BigDecimal; +import java.util.concurrent.CompletableFuture; + +public abstract class PresentationPreparation { + protected CompletableFuture parseUserId(String s) { + try { + return CompletableFuture.completedFuture(new UserId(s)); + } catch (RuntimeException e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException(e.getMessage())); + return failed; + } + } + + protected CompletableFuture parseAmount(String s) { + try { + return CompletableFuture.completedFuture(new BigDecimal(s)); + } catch (RuntimeException e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException("invalid amount: " + s)); + return failed; + } + } +} diff --git a/java17/src/test/java/folio/codinginterview/OptimalPortfolioScenarioTest.java b/java17/src/test/java/folio/codinginterview/OptimalPortfolioScenarioTest.java new file mode 100644 index 0000000..f86fe63 --- /dev/null +++ b/java17/src/test/java/folio/codinginterview/OptimalPortfolioScenarioTest.java @@ -0,0 +1,57 @@ +package folio.codinginterview; + +import folio.codinginterview.infrastructure.server.DummyServer; +import folio.codinginterview.presentation.PortfolioController; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OptimalPortfolioScenarioTest { + + private static void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { + assertTrue(expected.compareTo(actual) == 0, "expected " + expected + " but was " + actual); + } + + @Test + void 最適ポートフォリオを更新取得できる() throws Exception { + var server = DummyServer.defaultInstance(); + var pc = server.portfolioController(); + + // Given: 最適ポートフォリオを Toyopa=0.20, Somy=0.80 に更新する + pc.updateOptimalPortfolio(new PortfolioController.UpdateOptimalPortfolioRequest(List.of( + new PortfolioController.PortfolioItemDto("Toyopa", "0.20"), + new PortfolioController.PortfolioItemDto("Somy", "0.80") + ))).get(); + + // When: 最適ポートフォリオを取得する + var first = pc.getOptimalPortfolio().get(); + Map firstMap = new HashMap<>(); + for (var p : first.portfolios()) { + firstMap.put(p.symbol(), p.rate()); + } + + // Then: Toyopa=0.20, Somy=0.80 が返される + assertBigDecimalEquals(new BigDecimal("0.20"), new BigDecimal(firstMap.get("Toyopa"))); + assertBigDecimalEquals(new BigDecimal("0.80"), new BigDecimal(firstMap.get("Somy"))); + + // When: 最適ポートフォリオを Toyopa=0.40, Somy=0.60 に更新して再取得する + pc.updateOptimalPortfolio(new PortfolioController.UpdateOptimalPortfolioRequest(List.of( + new PortfolioController.PortfolioItemDto("Toyopa", "0.40"), + new PortfolioController.PortfolioItemDto("Somy", "0.60") + ))).get(); + var second = pc.getOptimalPortfolio().get(); + Map secondMap = new HashMap<>(); + for (var p : second.portfolios()) { + secondMap.put(p.symbol(), p.rate()); + } + + // Then: Toyopa=0.40, Somy=0.60 が返される + assertBigDecimalEquals(new BigDecimal("0.40"), new BigDecimal(secondMap.get("Toyopa"))); + assertBigDecimalEquals(new BigDecimal("0.60"), new BigDecimal(secondMap.get("Somy"))); + } +} diff --git a/java17/src/test/java/folio/codinginterview/OrderScenarioTest.java b/java17/src/test/java/folio/codinginterview/OrderScenarioTest.java new file mode 100644 index 0000000..e9c1e20 --- /dev/null +++ b/java17/src/test/java/folio/codinginterview/OrderScenarioTest.java @@ -0,0 +1,134 @@ +package folio.codinginterview; + +import folio.codinginterview.infrastructure.server.DummyServer; +import folio.codinginterview.presentation.AssetController; +import folio.codinginterview.presentation.MarketPriceController; +import folio.codinginterview.presentation.OrderController; +import folio.codinginterview.presentation.PortfolioController; +import folio.codinginterview.presentation.PresentationException.BadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class OrderScenarioTest { + + private static void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { + assertTrue(expected.compareTo(actual) == 0, "expected " + expected + " but was " + actual); + } + + private final DummyServer server = DummyServer.defaultInstance(); + private final AssetController ac = server.assetController(); + private final PortfolioController pc = server.portfolioController(); + private final OrderController oc = server.orderController(); + private final MarketPriceController mp = server.marketPriceController(); + + @BeforeEach + void setUp() throws Exception { + // initialize market price and optimal portfolio + pc.updateOptimalPortfolio(new PortfolioController.UpdateOptimalPortfolioRequest(List.of( + new PortfolioController.PortfolioItemDto("Toyopa", "0.40"), + new PortfolioController.PortfolioItemDto("Somy", "0.60") + ))).get(); + mp.updateMarketPrice(new MarketPriceController.UpdateMarketPriceRequest(List.of( + new MarketPriceController.MarketPriceItemDto("Toyopa", "2.5"), + new MarketPriceController.MarketPriceItemDto("Somy", "3.0") + ))).get(); + } + + private Throwable unwrap(Throwable e) { + while (e instanceof ExecutionException || e instanceof CompletionException) { + if (e.getCause() == null) break; + e = e.getCause(); + } + return e; + } + + @Test + void 新規拠出追加拠出リバランスの一連の操作が正しく機能する() throws Exception { + String userId = UUID.randomUUID().toString(); + + // Given: 存在しないユーザーで資産を取得しようとする + Throwable notFound = null; + try { + ac.getAsset(new AssetController.GetAssetRequest(userId)).get(); + fail("expected exception"); + } catch (Exception e) { + notFound = unwrap(e); + } + // Then: BadRequestException が返される + assertTrue(notFound instanceof BadRequestException); + + // When: 最適ポートフォリオを Toyopa=40%, Somy=60% に更新する + pc.updateOptimalPortfolio(new PortfolioController.UpdateOptimalPortfolioRequest(List.of( + new PortfolioController.PortfolioItemDto("Toyopa", "0.40"), + new PortfolioController.PortfolioItemDto("Somy", "0.60") + ))).get(); + + // And: 新規拠出を 100,000 円で注文する + oc.newContributionOrder(new OrderController.NewContributionOrderRequest(userId, "100000")).get(); + + var asset1 = ac.getAsset(new AssetController.GetAssetRequest(userId)).get(); + var symbols1 = asset1.stocks().stream().map(AssetController.StockDto::symbol).toList(); + assertTrue(symbols1.contains("Toyopa") && symbols1.contains("Somy") && symbols1.size() == 2); + BigDecimal total1 = new BigDecimal(asset1.cashAmount()); + for (var e : asset1.stocks()) { + total1 = total1.add(new BigDecimal(e.evaluationAmount())); + } + assertTrue(total1.subtract(new BigDecimal(100000)).abs().compareTo(new BigDecimal(2)) <= 0); + + // Then: 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる + var asset1Toyopa = asset1.stocks().stream().filter(e -> e.symbol().equals("Toyopa")).findFirst().orElseThrow(); + var asset1Somy = asset1.stocks().stream().filter(e -> e.symbol().equals("Somy")).findFirst().orElseThrow(); + assertBigDecimalEquals(new BigDecimal("38000"), new BigDecimal(asset1Toyopa.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("57000"), new BigDecimal(asset1Somy.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("5000"), new BigDecimal(asset1.cashAmount())); + + // When: 追加拠出を 100,000 円で注文する + oc.additionalContributionOrder(new OrderController.AdditionalContributionOrderRequest(userId, "100000")).get(); + + // Then: 資産合計が約 200,000 円になる + var asset2 = ac.getAsset(new AssetController.GetAssetRequest(userId)).get(); + BigDecimal total2 = new BigDecimal(asset2.cashAmount()); + for (var e : asset2.stocks()) { + total2 = total2.add(new BigDecimal(e.evaluationAmount())); + } + assertTrue(total2.subtract(new BigDecimal(200000)).abs().compareTo(new BigDecimal(4)) <= 0); + + // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる + var asset2Toyopa = asset2.stocks().stream().filter(e -> e.symbol().equals("Toyopa")).findFirst().orElseThrow(); + var asset2Somy = asset2.stocks().stream().filter(e -> e.symbol().equals("Somy")).findFirst().orElseThrow(); + assertBigDecimalEquals(new BigDecimal("76000"), new BigDecimal(asset2Toyopa.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("114000"), new BigDecimal(asset2Somy.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("10000"), new BigDecimal(asset2.cashAmount())); + + // When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする + pc.updateOptimalPortfolio(new PortfolioController.UpdateOptimalPortfolioRequest(List.of( + new PortfolioController.PortfolioItemDto("Toyopa", "0.10"), + new PortfolioController.PortfolioItemDto("Somy", "0.90") + ))).get(); + oc.rebalanceOrder(new OrderController.RebalanceOrderRequest(userId)).get(); + + // Then: リバランス後も資産合計がほぼ変わらない + var asset3 = ac.getAsset(new AssetController.GetAssetRequest(userId)).get(); + BigDecimal total3 = new BigDecimal(asset3.cashAmount()); + for (var e : asset3.stocks()) { + total3 = total3.add(new BigDecimal(e.evaluationAmount())); + } + assertTrue(total3.subtract(total2).abs().compareTo(new BigDecimal(4)) <= 0); + + // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる + var asset3Toyopa = asset3.stocks().stream().filter(e -> e.symbol().equals("Toyopa")).findFirst().orElseThrow(); + var asset3Somy = asset3.stocks().stream().filter(e -> e.symbol().equals("Somy")).findFirst().orElseThrow(); + assertBigDecimalEquals(new BigDecimal("19000"), new BigDecimal(asset3Toyopa.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("171000"), new BigDecimal(asset3Somy.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("10000"), new BigDecimal(asset3.cashAmount())); + } +} diff --git a/java8/.gitignore b/java8/.gitignore new file mode 100644 index 0000000..fa3306b --- /dev/null +++ b/java8/.gitignore @@ -0,0 +1,30 @@ +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/4d602a24bc5e4bbc2b8cedf08d4e982a80a7dfea/Java.gitignore + +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +target/ +.idea/ diff --git a/java8/.mvn/wrapper/maven-wrapper.properties b/java8/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..8dea6c2 --- /dev/null +++ b/java8/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/java8/README.md b/java8/README.md new file mode 100644 index 0000000..f41a19f --- /dev/null +++ b/java8/README.md @@ -0,0 +1,67 @@ +# サンプルラップサービス + +## 開発 + +- Java 8+ + +```shell +# 準備 +git init && git add . && git commit -m init + +# セットアップ +./mvnw test-compile + +# テスト実行 +./mvnw test +``` + +## サービス概要 + +このアプリケーションはロボアドバイザーサービスのバックエンドです。 + +### 株と評価額 + +- 株には株数(qty)があります(例: 1株、2株) +- 各株には1株あたりの市場価格があります(例: 1株あたり100円) +- 例: 顧客が5株保有している場合、評価額は `5株 × 100円 = 500円` となります + +### ロボアドバイザーサービス + +- **顧客の口座** + - 新規拠出を行うと、口座がすぐに開きます + - 口座の中で資産を管理することになります +- **顧客の資産** + - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します + - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円をいくつかの株で保有する + - 株は価格で保持するのではなく、株数で保持します + - そのため、市場価格に応じて評価額は変わることになります +- **最適ポートフォリオ** + - サービスが管理する、株の評価額ベースの構成比率 + - 例: A株を評価額の30%B株を評価額の70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30% B株95万円*70% になるように努める + - 購入時・売却時・リバランス時には、売買後の資産比率が現在の最適ポートフォリオに近づく形での売買を実施します +- **株の売買** + - 本アプリケーションでは、注文APIを叩くと即時必要な株の売買が成立し資産に反映出来るものとします +- 用語 + - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 + - 全売却注文: 運用中の株を全て売却すること。 + - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + +## 確認観点 + +- 成果を出すこと +- 成果物についての理解・責任を持つこと + +## 課題 + +以下の課題をAIを用いて、あなた自身の言葉・実装で回答してください。 + +1. API/アーキテクチャについてAIと協力しながら自分の言葉で説明してください +2. テストが全て通るようにしてください + - まずは現状のテストを走らせて、どのような内容で落ちているかを説明してください + - また、実装バグがあるため、テストコードは一切変更せず、AIと協力しながら実装を修正してください + - その上で、修正内容について説明をしてください +3. 全売却APIを実装してください + - 全売却後の現金の取り扱いに関しての方針を決めてください + - ストレッチ: 売却後の現金は銀行APIへ連携 +4. (部分売却APIを実装してください) diff --git a/java8/mvnw b/java8/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/java8/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/java8/mvnw.cmd b/java8/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/java8/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/java8/pom.xml b/java8/pom.xml new file mode 100644 index 0000000..ae5a456 --- /dev/null +++ b/java8/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + folio + coding-interview + 0.1.0-SNAPSHOT + jar + + + 1.8 + 1.8 + UTF-8 + 5.10.2 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + diff --git a/java8/src/main/java/folio/codinginterview/application/repository/AccountRepository.java b/java8/src/main/java/folio/codinginterview/application/repository/AccountRepository.java new file mode 100644 index 0000000..6d1c382 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/repository/AccountRepository.java @@ -0,0 +1,18 @@ +package folio.codinginterview.application.repository; + +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.UserId; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +/** + * 口座管理リポジトリ。 + */ +public interface AccountRepository { + CompletableFuture> find(UserId userId); + + CompletableFuture upsert(UserId userId, Account account); + + CompletableFuture exists(UserId userId); +} diff --git a/java8/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java b/java8/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java new file mode 100644 index 0000000..499c187 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java @@ -0,0 +1,16 @@ +package folio.codinginterview.application.repository; + +import folio.codinginterview.domain.StockSymbol; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * 市場価格リポジトリ。 + */ +public interface MarketPriceRepository { + CompletableFuture> all(); + + CompletableFuture update(Map prices); +} diff --git a/java8/src/main/java/folio/codinginterview/application/repository/PortfolioRepository.java b/java8/src/main/java/folio/codinginterview/application/repository/PortfolioRepository.java new file mode 100644 index 0000000..f4263e5 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/repository/PortfolioRepository.java @@ -0,0 +1,14 @@ +package folio.codinginterview.application.repository; + +import folio.codinginterview.domain.Portfolio; + +import java.util.concurrent.CompletableFuture; + +/** + * 最適ポートフォリオリポジトリ。 + */ +public interface PortfolioRepository { + CompletableFuture get(); + + CompletableFuture update(Portfolio portfolio); +} diff --git a/java8/src/main/java/folio/codinginterview/application/service/AssetService.java b/java8/src/main/java/folio/codinginterview/application/service/AssetService.java new file mode 100644 index 0000000..89b0f32 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/service/AssetService.java @@ -0,0 +1,28 @@ +package folio.codinginterview.application.service; + +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.Stock; +import folio.codinginterview.domain.StockSymbol; + +import java.math.BigDecimal; +import java.util.Map; + +public final class AssetService { + private AssetService() {} + + public static BigDecimal evaluateStock(Stock stock, Map prices) { + BigDecimal price = prices.get(stock.symbol()); + if (price == null) { + throw new IllegalStateException("missing price for " + stock.symbol()); + } + return stock.qty().multiply(price); + } + + public static BigDecimal totalValuation(Account account, Map prices) { + BigDecimal sum = BigDecimal.ZERO; + for (Stock e : account.stocks()) { + sum = sum.add(evaluateStock(e, prices)); + } + return sum.add(account.cash()); + } +} diff --git a/java8/src/main/java/folio/codinginterview/application/service/PortfolioService.java b/java8/src/main/java/folio/codinginterview/application/service/PortfolioService.java new file mode 100644 index 0000000..474f0c5 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/service/PortfolioService.java @@ -0,0 +1,130 @@ +package folio.codinginterview.application.service; + +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.AppConstants; +import folio.codinginterview.domain.Stock; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.domain.Portfolio; +import folio.codinginterview.domain.PortfolioItem; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class PortfolioService { + private PortfolioService() {} + + private static BigDecimal floor2(BigDecimal x) { + return x.setScale(2, RoundingMode.DOWN); + } + + private static BigDecimal floor0(BigDecimal x) { + return x.setScale(0, RoundingMode.DOWN); + } + + private static BigDecimal priceOf(Map prices, StockSymbol symbol) { + BigDecimal p = prices.get(symbol); + if (p == null) { + throw new IllegalStateException("missing price for " + symbol); + } + return p; + } + + /** Allocate a brand-new account given a contribution amount. */ + public static Account allocateNew( + BigDecimal amount, + Portfolio portfolio, + Map prices + ) { + BigDecimal cashFromRate = floor0(amount.multiply(AppConstants.CASH_RATE)); + BigDecimal investable = amount.subtract(cashFromRate); + List stocks = new ArrayList<>(); + for (PortfolioItem item : portfolio.items()) { + BigDecimal price = priceOf(prices, item.symbol()); + BigDecimal qty = floor2(investable.multiply(item.rate()).divide(price, 20, RoundingMode.DOWN)); + stocks.add(new Stock(item.symbol(), qty)); + } + BigDecimal usedForStocks = BigDecimal.ZERO; + for (Stock e : stocks) { + usedForStocks = usedForStocks.add(e.qty().multiply(priceOf(prices, e.symbol()))); + } + BigDecimal residual = investable.subtract(usedForStocks); + return new Account(cashFromRate.add(residual), stocks); + } + + /** Additional contribution: only buy (no sell). Residual is kept in cash. */ + public static Account allocateAdditional( + Account account, + BigDecimal amount, + Portfolio portfolio, + Map prices + ) { + BigDecimal totalAfter = AssetService.totalValuation(account, prices).add(amount); + BigDecimal targetCash = floor0(totalAfter.multiply(AppConstants.CASH_RATE)); + BigDecimal investable = totalAfter.subtract(targetCash); + + Map currentQty = new HashMap<>(); + for (Stock e : account.stocks()) { + currentQty.put(e.symbol(), e.qty()); + } + + Set portfolioSymbols = new HashSet<>(); + for (PortfolioItem item : portfolio.items()) { + portfolioSymbols.add(item.symbol()); + } + + List newPortfolioStocks = new ArrayList<>(); + for (PortfolioItem item : portfolio.items()) { + BigDecimal price = priceOf(prices, item.symbol()); + BigDecimal targetQty = floor2(investable.multiply(item.rate()).divide(price, 20, RoundingMode.DOWN)); + BigDecimal current = currentQty.getOrDefault(item.symbol(), BigDecimal.ZERO); + BigDecimal finalQty = targetQty.compareTo(current) > 0 ? targetQty : current; + newPortfolioStocks.add(new Stock(item.symbol(), finalQty)); + } + + List preservedStocks = new ArrayList<>(); + for (Stock e : account.stocks()) { + if (!portfolioSymbols.contains(e.symbol())) { + preservedStocks.add(e); + } + } + + List allStocks = new ArrayList<>(); + allStocks.addAll(newPortfolioStocks); + allStocks.addAll(preservedStocks); + + BigDecimal finalValuation = BigDecimal.ZERO; + for (Stock e : allStocks) { + finalValuation = finalValuation.add(e.qty().multiply(priceOf(prices, e.symbol()))); + } + BigDecimal finalCash = totalAfter.subtract(finalValuation); + return new Account(finalCash, allStocks); + } + + /** Rebalance: re-allocate qty per portfolio target (buy and sell). */ + public static Account rebalance( + Account account, + Portfolio portfolio, + Map prices + ) { + // XXX this implementation might not be correct + BigDecimal investable = AssetService.totalValuation(account, prices); + List newStocks = new ArrayList<>(); + for (PortfolioItem item : portfolio.items()) { + BigDecimal price = priceOf(prices, item.symbol()); + BigDecimal qty = floor2(investable.multiply(item.rate()).divide(price, 20, RoundingMode.DOWN)); + newStocks.add(new Stock(item.symbol(), qty)); + } + BigDecimal finalValuation = BigDecimal.ZERO; + for (Stock e : newStocks) { + finalValuation = finalValuation.add(e.qty().multiply(priceOf(prices, e.symbol()))); + } + BigDecimal finalCash = investable.subtract(finalValuation); + return new Account(finalCash, newStocks); + } +} diff --git a/java8/src/main/java/folio/codinginterview/application/usecase/asset/GetAssetUsecase.java b/java8/src/main/java/folio/codinginterview/application/usecase/asset/GetAssetUsecase.java new file mode 100644 index 0000000..4d14249 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/usecase/asset/GetAssetUsecase.java @@ -0,0 +1,81 @@ +package folio.codinginterview.application.usecase.asset; + +import folio.codinginterview.application.repository.AccountRepository; +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.application.service.AssetService; +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.Stock; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.domain.UserId; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +public final class GetAssetUsecase { + public static final class Input { + private final UserId userId; + public Input(UserId userId) { this.userId = userId; } + public UserId userId() { return userId; } + } + + public static final class StockOutput { + private final StockSymbol symbol; + private final BigDecimal evaluationAmount; + public StockOutput(StockSymbol symbol, BigDecimal evaluationAmount) { + this.symbol = symbol; + this.evaluationAmount = evaluationAmount; + } + public StockSymbol symbol() { return symbol; } + public BigDecimal evaluationAmount() { return evaluationAmount; } + } + + public static final class Output { + private final BigDecimal cashAmount; + private final List stocks; + public Output(BigDecimal cashAmount, List stocks) { + this.cashAmount = cashAmount; + this.stocks = Collections.unmodifiableList(new ArrayList<>(stocks)); + } + public BigDecimal cashAmount() { return cashAmount; } + public List stocks() { return stocks; } + } + + public static class Exception extends RuntimeException { + protected Exception(String msg) { super(msg); } + } + + public static final class UserNotFound extends Exception { + public static final UserNotFound INSTANCE = new UserNotFound(); + private UserNotFound() { super("user not found"); } + } + + private final AccountRepository accountRepository; + private final MarketPriceRepository marketPriceRepository; + + public GetAssetUsecase(AccountRepository accountRepository, MarketPriceRepository marketPriceRepository) { + this.accountRepository = accountRepository; + this.marketPriceRepository = marketPriceRepository; + } + + public CompletableFuture run(Input input) { + return accountRepository.find(input.userId()).thenCompose((Optional maybeAccount) -> { + if (!maybeAccount.isPresent()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(UserNotFound.INSTANCE); + return failed; + } + Account account = maybeAccount.get(); + return marketPriceRepository.all().thenApply(prices -> { + List stocks = new ArrayList<>(); + for (Stock e : account.stocks()) { + stocks.add(new StockOutput(e.symbol(), AssetService.evaluateStock(e, prices))); + } + return new Output(account.cash(), stocks); + }); + }); + } +} diff --git a/java8/src/main/java/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.java b/java8/src/main/java/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.java new file mode 100644 index 0000000..6cc6c99 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.java @@ -0,0 +1,47 @@ +package folio.codinginterview.application.usecase.market_price; + +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.domain.StockSymbol; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public final class UpdateMarketPriceUsecase { + public static final class ItemInput { + private final StockSymbol symbol; + private final BigDecimal marketPrice; + public ItemInput(StockSymbol symbol, BigDecimal marketPrice) { + this.symbol = symbol; + this.marketPrice = marketPrice; + } + public StockSymbol symbol() { return symbol; } + public BigDecimal marketPrice() { return marketPrice; } + } + + public static final class Input { + private final List items; + public Input(List items) { + this.items = Collections.unmodifiableList(new ArrayList<>(items)); + } + public List items() { return items; } + } + + private final MarketPriceRepository marketPriceRepository; + + public UpdateMarketPriceUsecase(MarketPriceRepository marketPriceRepository) { + this.marketPriceRepository = marketPriceRepository; + } + + public CompletableFuture run(Input input) { + Map prices = new LinkedHashMap<>(); + for (ItemInput i : input.items()) { + prices.put(i.symbol(), i.marketPrice()); + } + return marketPriceRepository.update(prices); + } +} diff --git a/java8/src/main/java/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.java b/java8/src/main/java/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.java new file mode 100644 index 0000000..b1de329 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.java @@ -0,0 +1,75 @@ +package folio.codinginterview.application.usecase.order; + +import folio.codinginterview.application.repository.AccountRepository; +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.application.service.PortfolioService; +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.AppConstants; +import folio.codinginterview.domain.UserId; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +public final class AdditionalBuyOrderUsecase { + public static final class Input { + private final UserId userId; + private final BigDecimal amount; + public Input(UserId userId, BigDecimal amount) { + this.userId = userId; + this.amount = amount; + } + public UserId userId() { return userId; } + public BigDecimal amount() { return amount; } + } + + public static class Exception extends RuntimeException { + protected Exception(String msg) { super(msg); } + } + + public static final class UserNotFound extends Exception { + public static final UserNotFound INSTANCE = new UserNotFound(); + private UserNotFound() { super("user not found"); } + } + + public static final class AmountTooSmall extends Exception { + public static final AmountTooSmall INSTANCE = new AmountTooSmall(); + private AmountTooSmall() { super("amount too small"); } + } + + private final AccountRepository accountRepository; + private final PortfolioRepository portfolioRepository; + private final MarketPriceRepository marketPriceRepository; + + public AdditionalBuyOrderUsecase( + AccountRepository accountRepository, + PortfolioRepository portfolioRepository, + MarketPriceRepository marketPriceRepository + ) { + this.accountRepository = accountRepository; + this.portfolioRepository = portfolioRepository; + this.marketPriceRepository = marketPriceRepository; + } + + public CompletableFuture run(Input input) { + if (input.amount().compareTo(AppConstants.MIN_OPERATION_AMOUNT) < 0) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(AmountTooSmall.INSTANCE); + return failed; + } + return accountRepository.find(input.userId()).thenCompose((Optional maybeAccount) -> { + if (!maybeAccount.isPresent()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(UserNotFound.INSTANCE); + return failed; + } + Account account = maybeAccount.get(); + return portfolioRepository.get().thenCompose(portfolio -> + marketPriceRepository.all().thenCompose(prices -> { + Account updated = PortfolioService.allocateAdditional(account, input.amount(), portfolio, prices); + return accountRepository.upsert(input.userId(), updated); + })); + }); + } +} diff --git a/java8/src/main/java/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.java b/java8/src/main/java/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.java new file mode 100644 index 0000000..d7a5320 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.java @@ -0,0 +1,73 @@ +package folio.codinginterview.application.usecase.order; + +import folio.codinginterview.application.repository.AccountRepository; +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.application.service.PortfolioService; +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.AppConstants; +import folio.codinginterview.domain.UserId; + +import java.math.BigDecimal; +import java.util.concurrent.CompletableFuture; + +public final class NewContributionOrderUsecase { + public static final class Input { + private final UserId userId; + private final BigDecimal amount; + public Input(UserId userId, BigDecimal amount) { + this.userId = userId; + this.amount = amount; + } + public UserId userId() { return userId; } + public BigDecimal amount() { return amount; } + } + + public static class Exception extends RuntimeException { + protected Exception(String msg) { super(msg); } + } + + public static final class UserAlreadyExists extends Exception { + public static final UserAlreadyExists INSTANCE = new UserAlreadyExists(); + private UserAlreadyExists() { super("user already exists"); } + } + + public static final class AmountTooSmall extends Exception { + public static final AmountTooSmall INSTANCE = new AmountTooSmall(); + private AmountTooSmall() { super("amount too small"); } + } + + private final AccountRepository accountRepository; + private final PortfolioRepository portfolioRepository; + private final MarketPriceRepository marketPriceRepository; + + public NewContributionOrderUsecase( + AccountRepository accountRepository, + PortfolioRepository portfolioRepository, + MarketPriceRepository marketPriceRepository + ) { + this.accountRepository = accountRepository; + this.portfolioRepository = portfolioRepository; + this.marketPriceRepository = marketPriceRepository; + } + + public CompletableFuture run(Input input) { + if (input.amount().compareTo(AppConstants.MIN_OPERATION_AMOUNT) < 0) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(AmountTooSmall.INSTANCE); + return failed; + } + return accountRepository.exists(input.userId()).thenCompose(exists -> { + if (exists) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(UserAlreadyExists.INSTANCE); + return failed; + } + return portfolioRepository.get().thenCompose(portfolio -> + marketPriceRepository.all().thenCompose(prices -> { + Account account = PortfolioService.allocateNew(input.amount(), portfolio, prices); + return accountRepository.upsert(input.userId(), account); + })); + }); + } +} diff --git a/java8/src/main/java/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.java b/java8/src/main/java/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.java new file mode 100644 index 0000000..c13448b --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.java @@ -0,0 +1,58 @@ +package folio.codinginterview.application.usecase.order; + +import folio.codinginterview.application.repository.AccountRepository; +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.application.service.PortfolioService; +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.UserId; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +public final class RebalanceOrderUsecase { + public static final class Input { + private final UserId userId; + public Input(UserId userId) { this.userId = userId; } + public UserId userId() { return userId; } + } + + public static class Exception extends RuntimeException { + protected Exception(String msg) { super(msg); } + } + + public static final class UserNotFound extends Exception { + public static final UserNotFound INSTANCE = new UserNotFound(); + private UserNotFound() { super("user not found"); } + } + + private final AccountRepository accountRepository; + private final PortfolioRepository portfolioRepository; + private final MarketPriceRepository marketPriceRepository; + + public RebalanceOrderUsecase( + AccountRepository accountRepository, + PortfolioRepository portfolioRepository, + MarketPriceRepository marketPriceRepository + ) { + this.accountRepository = accountRepository; + this.portfolioRepository = portfolioRepository; + this.marketPriceRepository = marketPriceRepository; + } + + public CompletableFuture run(Input input) { + return accountRepository.find(input.userId()).thenCompose((Optional maybeAccount) -> { + if (!maybeAccount.isPresent()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(UserNotFound.INSTANCE); + return failed; + } + Account account = maybeAccount.get(); + return portfolioRepository.get().thenCompose(portfolio -> + marketPriceRepository.all().thenCompose(prices -> { + Account updated = PortfolioService.rebalance(account, portfolio, prices); + return accountRepository.upsert(input.userId(), updated); + })); + }); + } +} diff --git a/java8/src/main/java/folio/codinginterview/application/usecase/portfolio/GetLatestPortfolioUsecase.java b/java8/src/main/java/folio/codinginterview/application/usecase/portfolio/GetLatestPortfolioUsecase.java new file mode 100644 index 0000000..581676c --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/usecase/portfolio/GetLatestPortfolioUsecase.java @@ -0,0 +1,48 @@ +package folio.codinginterview.application.usecase.portfolio; + +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.domain.PortfolioItem; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public final class GetLatestPortfolioUsecase { + public static final class ItemOutput { + private final StockSymbol symbol; + private final BigDecimal rate; + public ItemOutput(StockSymbol symbol, BigDecimal rate) { + this.symbol = symbol; + this.rate = rate; + } + public StockSymbol symbol() { return symbol; } + public BigDecimal rate() { return rate; } + } + + public static final class Output { + private final List items; + public Output(List items) { + this.items = Collections.unmodifiableList(new ArrayList<>(items)); + } + public List items() { return items; } + } + + private final PortfolioRepository portfolioRepository; + + public GetLatestPortfolioUsecase(PortfolioRepository portfolioRepository) { + this.portfolioRepository = portfolioRepository; + } + + public CompletableFuture run() { + return portfolioRepository.get().thenApply(p -> { + List items = new ArrayList<>(); + for (PortfolioItem i : p.items()) { + items.add(new ItemOutput(i.symbol(), i.rate())); + } + return new Output(items); + }); + } +} diff --git a/java8/src/main/java/folio/codinginterview/application/usecase/portfolio/UpdatePortfolioUsecase.java b/java8/src/main/java/folio/codinginterview/application/usecase/portfolio/UpdatePortfolioUsecase.java new file mode 100644 index 0000000..1a796e1 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/application/usecase/portfolio/UpdatePortfolioUsecase.java @@ -0,0 +1,63 @@ +package folio.codinginterview.application.usecase.portfolio; + +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.domain.Portfolio; +import folio.codinginterview.domain.PortfolioItem; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public final class UpdatePortfolioUsecase { + public static final class ItemInput { + private final StockSymbol symbol; + private final BigDecimal rate; + public ItemInput(StockSymbol symbol, BigDecimal rate) { + this.symbol = symbol; + this.rate = rate; + } + public StockSymbol symbol() { return symbol; } + public BigDecimal rate() { return rate; } + } + + public static final class Input { + private final List items; + public Input(List items) { + this.items = Collections.unmodifiableList(new ArrayList<>(items)); + } + public List items() { return items; } + } + + public static class Exception extends RuntimeException { + protected Exception(String msg) { super(msg); } + } + + public static final class InvalidPortfolio extends Exception { + public InvalidPortfolio(String reason) { super(reason); } + } + + private final PortfolioRepository portfolioRepository; + + public UpdatePortfolioUsecase(PortfolioRepository portfolioRepository) { + this.portfolioRepository = portfolioRepository; + } + + public CompletableFuture run(Input input) { + Portfolio portfolio; + try { + List items = new ArrayList<>(); + for (ItemInput i : input.items()) { + items.add(new PortfolioItem(i.symbol(), i.rate())); + } + portfolio = new Portfolio(items); + } catch (RuntimeException e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new InvalidPortfolio(e.getMessage())); + return failed; + } + return portfolioRepository.update(portfolio); + } +} diff --git a/java8/src/main/java/folio/codinginterview/domain/Account.java b/java8/src/main/java/folio/codinginterview/domain/Account.java new file mode 100644 index 0000000..779193b --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/domain/Account.java @@ -0,0 +1,20 @@ +package folio.codinginterview.domain; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class Account { + private final BigDecimal cash; + private final List stocks; + + public Account(BigDecimal cash, List stocks) { + this.cash = cash; + this.stocks = Collections.unmodifiableList(new ArrayList<>(stocks)); + } + + public BigDecimal cash() { return cash; } + + public List stocks() { return stocks; } +} diff --git a/java8/src/main/java/folio/codinginterview/domain/AppConstants.java b/java8/src/main/java/folio/codinginterview/domain/AppConstants.java new file mode 100644 index 0000000..e32cb13 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/domain/AppConstants.java @@ -0,0 +1,33 @@ +package folio.codinginterview.domain; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class AppConstants { + private AppConstants() {} + + public static final BigDecimal CASH_RATE = new BigDecimal("0.05"); + + public static final BigDecimal MIN_OPERATION_AMOUNT = new BigDecimal(10000); + + public static final List SUPPORTED_SYMBOLS = + Collections.unmodifiableList(Arrays.asList(StockSymbol.Toyopa, StockSymbol.Somy)); + + public static final Map INITIAL_PRICES; + + public static final Portfolio INITIAL_PORTFOLIO = new Portfolio(Arrays.asList( + new PortfolioItem(StockSymbol.Toyopa, new BigDecimal("0.40")), + new PortfolioItem(StockSymbol.Somy, new BigDecimal("0.60")) + )); + + static { + Map p = new LinkedHashMap<>(); + p.put(StockSymbol.Toyopa, new BigDecimal("4.2135")); + p.put(StockSymbol.Somy, new BigDecimal("1.2345")); + INITIAL_PRICES = Collections.unmodifiableMap(p); + } +} diff --git a/java8/src/main/java/folio/codinginterview/domain/Portfolio.java b/java8/src/main/java/folio/codinginterview/domain/Portfolio.java new file mode 100644 index 0000000..cb56ec0 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/domain/Portfolio.java @@ -0,0 +1,35 @@ +package folio.codinginterview.domain; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class Portfolio { + private final List items; + + public Portfolio(List items) { + if (items == null || items.isEmpty()) { + throw new IllegalArgumentException("portfolio must have at least one item"); + } + BigDecimal sum = BigDecimal.ZERO; + for (PortfolioItem i : items) { + sum = sum.add(i.rate()); + } + if (sum.compareTo(BigDecimal.ONE) != 0) { + throw new IllegalArgumentException("portfolio rates must sum to 1, got " + sum); + } + Set symbols = new HashSet<>(); + for (PortfolioItem i : items) { + symbols.add(i.symbol()); + } + if (symbols.size() != items.size()) { + throw new IllegalArgumentException("portfolio must not have duplicate symbols"); + } + this.items = Collections.unmodifiableList(new ArrayList<>(items)); + } + + public List items() { return items; } +} diff --git a/java8/src/main/java/folio/codinginterview/domain/PortfolioItem.java b/java8/src/main/java/folio/codinginterview/domain/PortfolioItem.java new file mode 100644 index 0000000..3994326 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/domain/PortfolioItem.java @@ -0,0 +1,17 @@ +package folio.codinginterview.domain; + +import java.math.BigDecimal; + +public final class PortfolioItem { + private final StockSymbol symbol; + private final BigDecimal rate; + + public PortfolioItem(StockSymbol symbol, BigDecimal rate) { + this.symbol = symbol; + this.rate = rate; + } + + public StockSymbol symbol() { return symbol; } + + public BigDecimal rate() { return rate; } +} diff --git a/java8/src/main/java/folio/codinginterview/domain/Stock.java b/java8/src/main/java/folio/codinginterview/domain/Stock.java new file mode 100644 index 0000000..b1759d7 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/domain/Stock.java @@ -0,0 +1,17 @@ +package folio.codinginterview.domain; + +import java.math.BigDecimal; + +public final class Stock { + private final StockSymbol symbol; + private final BigDecimal qty; + + public Stock(StockSymbol symbol, BigDecimal qty) { + this.symbol = symbol; + this.qty = qty; + } + + public StockSymbol symbol() { return symbol; } + + public BigDecimal qty() { return qty; } +} diff --git a/java8/src/main/java/folio/codinginterview/domain/StockSymbol.java b/java8/src/main/java/folio/codinginterview/domain/StockSymbol.java new file mode 100644 index 0000000..8a18e18 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/domain/StockSymbol.java @@ -0,0 +1,13 @@ +package folio.codinginterview.domain; + +import java.util.Optional; + +public enum StockSymbol { + Toyopa, Somy; + + public static Optional fromString(String s) { + if ("Toyopa".equals(s)) return Optional.of(Toyopa); + if ("Somy".equals(s)) return Optional.of(Somy); + return Optional.empty(); + } +} diff --git a/java8/src/main/java/folio/codinginterview/domain/UserId.java b/java8/src/main/java/folio/codinginterview/domain/UserId.java new file mode 100644 index 0000000..018b02f --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/domain/UserId.java @@ -0,0 +1,36 @@ +package folio.codinginterview.domain; + +import java.util.Objects; + +public final class UserId { + private final String value; + + public UserId(String value) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException("userId must not be empty"); + } + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof UserId)) return false; + UserId other = (UserId) o; + return Objects.equals(value, other.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "UserId[value=" + value + "]"; + } +} diff --git a/java8/src/main/java/folio/codinginterview/infrastructure/repository/AccountRepositoryImpl.java b/java8/src/main/java/folio/codinginterview/infrastructure/repository/AccountRepositoryImpl.java new file mode 100644 index 0000000..74aecb2 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/infrastructure/repository/AccountRepositoryImpl.java @@ -0,0 +1,30 @@ +package folio.codinginterview.infrastructure.repository; + +import folio.codinginterview.application.repository.AccountRepository; +import folio.codinginterview.domain.Account; +import folio.codinginterview.domain.UserId; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public final class AccountRepositoryImpl implements AccountRepository { + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + @Override + public CompletableFuture> find(UserId userId) { + return CompletableFuture.completedFuture(Optional.ofNullable(store.get(userId.value()))); + } + + @Override + public CompletableFuture upsert(UserId userId, Account account) { + store.put(userId.value(), account); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture exists(UserId userId) { + return CompletableFuture.completedFuture(store.containsKey(userId.value())); + } +} diff --git a/java8/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java b/java8/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java new file mode 100644 index 0000000..fb4f676 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java @@ -0,0 +1,28 @@ +package folio.codinginterview.infrastructure.repository; + +import folio.codinginterview.application.repository.MarketPriceRepository; +import folio.codinginterview.domain.AppConstants; +import folio.codinginterview.domain.StockSymbol; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +public final class MarketPriceRepositoryImpl implements MarketPriceRepository { + private final AtomicReference> ref = + new AtomicReference<>(AppConstants.INITIAL_PRICES); + + @Override + public CompletableFuture> all() { + return CompletableFuture.completedFuture(ref.get()); + } + + @Override + public CompletableFuture update(Map prices) { + ref.set(Collections.unmodifiableMap(new LinkedHashMap<>(prices))); + return CompletableFuture.completedFuture(null); + } +} diff --git a/java8/src/main/java/folio/codinginterview/infrastructure/repository/PortfolioRepositoryImpl.java b/java8/src/main/java/folio/codinginterview/infrastructure/repository/PortfolioRepositoryImpl.java new file mode 100644 index 0000000..cf57b25 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/infrastructure/repository/PortfolioRepositoryImpl.java @@ -0,0 +1,23 @@ +package folio.codinginterview.infrastructure.repository; + +import folio.codinginterview.application.repository.PortfolioRepository; +import folio.codinginterview.domain.AppConstants; +import folio.codinginterview.domain.Portfolio; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +public final class PortfolioRepositoryImpl implements PortfolioRepository { + private final AtomicReference ref = new AtomicReference<>(AppConstants.INITIAL_PORTFOLIO); + + @Override + public CompletableFuture get() { + return CompletableFuture.completedFuture(ref.get()); + } + + @Override + public CompletableFuture update(Portfolio portfolio) { + ref.set(portfolio); + return CompletableFuture.completedFuture(null); + } +} diff --git a/java8/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java b/java8/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java new file mode 100644 index 0000000..a9ff784 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java @@ -0,0 +1,68 @@ +package folio.codinginterview.infrastructure.server; + +import folio.codinginterview.application.usecase.asset.GetAssetUsecase; +import folio.codinginterview.application.usecase.market_price.UpdateMarketPriceUsecase; +import folio.codinginterview.application.usecase.order.AdditionalBuyOrderUsecase; +import folio.codinginterview.application.usecase.order.NewContributionOrderUsecase; +import folio.codinginterview.application.usecase.order.RebalanceOrderUsecase; +import folio.codinginterview.application.usecase.portfolio.GetLatestPortfolioUsecase; +import folio.codinginterview.application.usecase.portfolio.UpdatePortfolioUsecase; +import folio.codinginterview.infrastructure.repository.AccountRepositoryImpl; +import folio.codinginterview.infrastructure.repository.MarketPriceRepositoryImpl; +import folio.codinginterview.infrastructure.repository.PortfolioRepositoryImpl; +import folio.codinginterview.presentation.AssetController; +import folio.codinginterview.presentation.MarketPriceController; +import folio.codinginterview.presentation.OrderController; +import folio.codinginterview.presentation.PortfolioController; + +public final class DummyServer { + private final AssetController assetController; + private final PortfolioController portfolioController; + private final OrderController orderController; + private final MarketPriceController marketPriceController; + + public DummyServer( + AssetController assetController, + PortfolioController portfolioController, + OrderController orderController, + MarketPriceController marketPriceController + ) { + this.assetController = assetController; + this.portfolioController = portfolioController; + this.orderController = orderController; + this.marketPriceController = marketPriceController; + } + + public AssetController assetController() { return assetController; } + + public PortfolioController portfolioController() { return portfolioController; } + + public OrderController orderController() { return orderController; } + + public MarketPriceController marketPriceController() { return marketPriceController; } + + public static DummyServer defaultInstance() { + PortfolioRepositoryImpl portfolioRepository = new PortfolioRepositoryImpl(); + AccountRepositoryImpl accountRepository = new AccountRepositoryImpl(); + MarketPriceRepositoryImpl marketPriceRepository = new MarketPriceRepositoryImpl(); + + GetAssetUsecase getAssetUsecase = new GetAssetUsecase(accountRepository, marketPriceRepository); + GetLatestPortfolioUsecase getLatestPortfolioUsecase = new GetLatestPortfolioUsecase(portfolioRepository); + UpdatePortfolioUsecase updatePortfolioUsecase = new UpdatePortfolioUsecase(portfolioRepository); + UpdateMarketPriceUsecase updateMarketPriceUsecase = new UpdateMarketPriceUsecase(marketPriceRepository); + NewContributionOrderUsecase newContributionOrderUsecase = new NewContributionOrderUsecase( + accountRepository, portfolioRepository, marketPriceRepository); + AdditionalBuyOrderUsecase additionalBuyOrderUsecase = new AdditionalBuyOrderUsecase( + accountRepository, portfolioRepository, marketPriceRepository); + RebalanceOrderUsecase rebalanceOrderUsecase = new RebalanceOrderUsecase( + accountRepository, portfolioRepository, marketPriceRepository); + + AssetController assetController = new AssetController(getAssetUsecase); + PortfolioController portfolioController = new PortfolioController(getLatestPortfolioUsecase, updatePortfolioUsecase); + OrderController orderController = new OrderController( + newContributionOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase); + MarketPriceController marketPriceController = new MarketPriceController(updateMarketPriceUsecase); + + return new DummyServer(assetController, portfolioController, orderController, marketPriceController); + } +} diff --git a/java8/src/main/java/folio/codinginterview/infrastructure/server/Main.java b/java8/src/main/java/folio/codinginterview/infrastructure/server/Main.java new file mode 100644 index 0000000..bb20878 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/infrastructure/server/Main.java @@ -0,0 +1,8 @@ +package folio.codinginterview.infrastructure.server; + +public final class Main { + public static void main(String[] args) { + DummyServer.defaultInstance(); + System.out.println("DummyServer initialized."); + } +} diff --git a/java8/src/main/java/folio/codinginterview/presentation/AssetController.java b/java8/src/main/java/folio/codinginterview/presentation/AssetController.java new file mode 100644 index 0000000..5cf2840 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/presentation/AssetController.java @@ -0,0 +1,65 @@ +package folio.codinginterview.presentation; + +import folio.codinginterview.application.usecase.asset.GetAssetUsecase; +import folio.codinginterview.presentation.PresentationException.BadRequestException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +public final class AssetController extends PresentationPreparation { + public static final class StockDto { + private final String symbol; + private final String evaluationAmount; + public StockDto(String symbol, String evaluationAmount) { + this.symbol = symbol; + this.evaluationAmount = evaluationAmount; + } + public String symbol() { return symbol; } + public String evaluationAmount() { return evaluationAmount; } + } + + public static final class GetAssetRequest { + private final String userId; + public GetAssetRequest(String userId) { this.userId = userId; } + public String userId() { return userId; } + } + + public static final class GetAssetResponse { + private final String cashAmount; + private final List stocks; + public GetAssetResponse(String cashAmount, List stocks) { + this.cashAmount = cashAmount; + this.stocks = Collections.unmodifiableList(new ArrayList<>(stocks)); + } + public String cashAmount() { return cashAmount; } + public List stocks() { return stocks; } + } + + private final GetAssetUsecase getAssetUsecase; + + public AssetController(GetAssetUsecase getAssetUsecase) { + this.getAssetUsecase = getAssetUsecase; + } + + public CompletableFuture getAsset(GetAssetRequest req) { + return parseUserId(req.userId()).thenCompose(uid -> + getAssetUsecase.run(new GetAssetUsecase.Input(uid)) + .handle((out, ex) -> { + if (ex != null) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof GetAssetUsecase.UserNotFound) { + throw new CompletionException(new BadRequestException("user not found")); + } + throw new CompletionException(cause); + } + List stocks = new ArrayList<>(); + for (GetAssetUsecase.StockOutput e : out.stocks()) { + stocks.add(new StockDto(e.symbol().toString(), e.evaluationAmount().toString())); + } + return new GetAssetResponse(out.cashAmount().toString(), stocks); + })); + } +} diff --git a/java8/src/main/java/folio/codinginterview/presentation/MarketPriceController.java b/java8/src/main/java/folio/codinginterview/presentation/MarketPriceController.java new file mode 100644 index 0000000..2a6a7ef --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/presentation/MarketPriceController.java @@ -0,0 +1,61 @@ +package folio.codinginterview.presentation; + +import folio.codinginterview.application.usecase.market_price.UpdateMarketPriceUsecase; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.presentation.PresentationException.BadRequestException; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +public final class MarketPriceController { + public static final class MarketPriceItemDto { + private final String symbol; + private final String market_price; + public MarketPriceItemDto(String symbol, String market_price) { + this.symbol = symbol; + this.market_price = market_price; + } + public String symbol() { return symbol; } + public String market_price() { return market_price; } + } + + public static final class UpdateMarketPriceRequest { + private final List market_prices; + public UpdateMarketPriceRequest(List market_prices) { + this.market_prices = Collections.unmodifiableList(new ArrayList<>(market_prices)); + } + public List market_prices() { return market_prices; } + } + + private final UpdateMarketPriceUsecase updateMarketPriceUsecase; + + public MarketPriceController(UpdateMarketPriceUsecase updateMarketPriceUsecase) { + this.updateMarketPriceUsecase = updateMarketPriceUsecase; + } + + public CompletableFuture updateMarketPrice(UpdateMarketPriceRequest req) { + List items = new ArrayList<>(); + for (MarketPriceItemDto dto : req.market_prices()) { + Optional sym = StockSymbol.fromString(dto.symbol()); + if (!sym.isPresent()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException("unknown symbol: " + dto.symbol())); + return failed; + } + BigDecimal price; + try { + price = new BigDecimal(dto.market_price()); + } catch (RuntimeException e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException("invalid market_price: " + dto.market_price())); + return failed; + } + items.add(new UpdateMarketPriceUsecase.ItemInput(sym.get(), price)); + } + return updateMarketPriceUsecase.run(new UpdateMarketPriceUsecase.Input(items)); + } +} diff --git a/java8/src/main/java/folio/codinginterview/presentation/OrderController.java b/java8/src/main/java/folio/codinginterview/presentation/OrderController.java new file mode 100644 index 0000000..63450aa --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/presentation/OrderController.java @@ -0,0 +1,106 @@ +package folio.codinginterview.presentation; + +import folio.codinginterview.application.usecase.order.AdditionalBuyOrderUsecase; +import folio.codinginterview.application.usecase.order.NewContributionOrderUsecase; +import folio.codinginterview.application.usecase.order.RebalanceOrderUsecase; +import folio.codinginterview.presentation.PresentationException.BadRequestException; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +public final class OrderController extends PresentationPreparation { + public static final class NewContributionOrderRequest { + private final String userId; + private final String amount; + public NewContributionOrderRequest(String userId, String amount) { + this.userId = userId; + this.amount = amount; + } + public String userId() { return userId; } + public String amount() { return amount; } + } + + public static final class AdditionalContributionOrderRequest { + private final String userId; + private final String amount; + public AdditionalContributionOrderRequest(String userId, String amount) { + this.userId = userId; + this.amount = amount; + } + public String userId() { return userId; } + public String amount() { return amount; } + } + + public static final class RebalanceOrderRequest { + private final String userId; + public RebalanceOrderRequest(String userId) { this.userId = userId; } + public String userId() { return userId; } + } + + private final NewContributionOrderUsecase newContributionOrderUsecase; + private final AdditionalBuyOrderUsecase additionalBuyOrderUsecase; + private final RebalanceOrderUsecase rebalanceOrderUsecase; + + public OrderController( + NewContributionOrderUsecase newContributionOrderUsecase, + AdditionalBuyOrderUsecase additionalBuyOrderUsecase, + RebalanceOrderUsecase rebalanceOrderUsecase + ) { + this.newContributionOrderUsecase = newContributionOrderUsecase; + this.additionalBuyOrderUsecase = additionalBuyOrderUsecase; + this.rebalanceOrderUsecase = rebalanceOrderUsecase; + } + + public CompletableFuture newContributionOrder(NewContributionOrderRequest req) { + return parseUserId(req.userId()).thenCompose(uid -> + parseAmount(req.amount()).thenCompose(amt -> + newContributionOrderUsecase.run(new NewContributionOrderUsecase.Input(uid, amt)) + .handle((v, ex) -> { + if (ex != null) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof NewContributionOrderUsecase.UserAlreadyExists) { + throw new CompletionException(new BadRequestException("user already has account")); + } + if (cause instanceof NewContributionOrderUsecase.AmountTooSmall) { + throw new CompletionException(new BadRequestException("amount is too small")); + } + throw new CompletionException(cause); + } + return null; + }))); + } + + public CompletableFuture additionalContributionOrder(AdditionalContributionOrderRequest req) { + return parseUserId(req.userId()).thenCompose(uid -> + parseAmount(req.amount()).thenCompose(amt -> + additionalBuyOrderUsecase.run(new AdditionalBuyOrderUsecase.Input(uid, amt)) + .handle((v, ex) -> { + if (ex != null) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof AdditionalBuyOrderUsecase.UserNotFound) { + throw new CompletionException(new BadRequestException("user has no live account")); + } + if (cause instanceof AdditionalBuyOrderUsecase.AmountTooSmall) { + throw new CompletionException(new BadRequestException("amount is too small")); + } + throw new CompletionException(cause); + } + return null; + }))); + } + + public CompletableFuture rebalanceOrder(RebalanceOrderRequest req) { + return parseUserId(req.userId()).thenCompose(uid -> + rebalanceOrderUsecase.run(new RebalanceOrderUsecase.Input(uid)) + .handle((v, ex) -> { + if (ex != null) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof RebalanceOrderUsecase.UserNotFound) { + throw new CompletionException(new BadRequestException("user has no live account")); + } + throw new CompletionException(cause); + } + return null; + })); + } +} diff --git a/java8/src/main/java/folio/codinginterview/presentation/PortfolioController.java b/java8/src/main/java/folio/codinginterview/presentation/PortfolioController.java new file mode 100644 index 0000000..0876049 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/presentation/PortfolioController.java @@ -0,0 +1,97 @@ +package folio.codinginterview.presentation; + +import folio.codinginterview.application.usecase.portfolio.GetLatestPortfolioUsecase; +import folio.codinginterview.application.usecase.portfolio.UpdatePortfolioUsecase; +import folio.codinginterview.domain.StockSymbol; +import folio.codinginterview.presentation.PresentationException.BadRequestException; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +public final class PortfolioController { + public static final class PortfolioItemDto { + private final String symbol; + private final String rate; + public PortfolioItemDto(String symbol, String rate) { + this.symbol = symbol; + this.rate = rate; + } + public String symbol() { return symbol; } + public String rate() { return rate; } + } + + public static final class GetOptimalPortfolioResponse { + private final List portfolios; + public GetOptimalPortfolioResponse(List portfolios) { + this.portfolios = Collections.unmodifiableList(new ArrayList<>(portfolios)); + } + public List portfolios() { return portfolios; } + } + + public static final class UpdateOptimalPortfolioRequest { + private final List portfolios; + public UpdateOptimalPortfolioRequest(List portfolios) { + this.portfolios = Collections.unmodifiableList(new ArrayList<>(portfolios)); + } + public List portfolios() { return portfolios; } + } + + private final GetLatestPortfolioUsecase getLatestPortfolioUsecase; + private final UpdatePortfolioUsecase updatePortfolioUsecase; + + public PortfolioController( + GetLatestPortfolioUsecase getLatestPortfolioUsecase, + UpdatePortfolioUsecase updatePortfolioUsecase + ) { + this.getLatestPortfolioUsecase = getLatestPortfolioUsecase; + this.updatePortfolioUsecase = updatePortfolioUsecase; + } + + public CompletableFuture getOptimalPortfolio() { + return getLatestPortfolioUsecase.run().thenApply(out -> { + List items = new ArrayList<>(); + for (GetLatestPortfolioUsecase.ItemOutput i : out.items()) { + items.add(new PortfolioItemDto(i.symbol().toString(), i.rate().toString())); + } + return new GetOptimalPortfolioResponse(items); + }); + } + + public CompletableFuture updateOptimalPortfolio(UpdateOptimalPortfolioRequest req) { + List items = new ArrayList<>(); + for (PortfolioItemDto dto : req.portfolios()) { + Optional sym = StockSymbol.fromString(dto.symbol()); + if (!sym.isPresent()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException("unknown symbol: " + dto.symbol())); + return failed; + } + BigDecimal rate; + try { + rate = new BigDecimal(dto.rate()); + } catch (RuntimeException e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException("invalid rate: " + dto.rate())); + return failed; + } + items.add(new UpdatePortfolioUsecase.ItemInput(sym.get(), rate)); + } + return updatePortfolioUsecase.run(new UpdatePortfolioUsecase.Input(items)) + .handle((v, ex) -> { + if (ex != null) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof UpdatePortfolioUsecase.InvalidPortfolio) { + UpdatePortfolioUsecase.InvalidPortfolio ip = (UpdatePortfolioUsecase.InvalidPortfolio) cause; + throw new CompletionException(new BadRequestException(ip.getMessage())); + } + throw new CompletionException(cause); + } + return null; + }); + } +} diff --git a/java8/src/main/java/folio/codinginterview/presentation/PresentationException.java b/java8/src/main/java/folio/codinginterview/presentation/PresentationException.java new file mode 100644 index 0000000..ae54154 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/presentation/PresentationException.java @@ -0,0 +1,13 @@ +package folio.codinginterview.presentation; + +public class PresentationException extends RuntimeException { + protected PresentationException(String message) { + super(message); + } + + public static final class BadRequestException extends PresentationException { + public BadRequestException(String message) { + super(message); + } + } +} diff --git a/java8/src/main/java/folio/codinginterview/presentation/PresentationPreparation.java b/java8/src/main/java/folio/codinginterview/presentation/PresentationPreparation.java new file mode 100644 index 0000000..41b4533 --- /dev/null +++ b/java8/src/main/java/folio/codinginterview/presentation/PresentationPreparation.java @@ -0,0 +1,29 @@ +package folio.codinginterview.presentation; + +import folio.codinginterview.domain.UserId; +import folio.codinginterview.presentation.PresentationException.BadRequestException; + +import java.math.BigDecimal; +import java.util.concurrent.CompletableFuture; + +public abstract class PresentationPreparation { + protected CompletableFuture parseUserId(String s) { + try { + return CompletableFuture.completedFuture(new UserId(s)); + } catch (RuntimeException e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException(e.getMessage())); + return failed; + } + } + + protected CompletableFuture parseAmount(String s) { + try { + return CompletableFuture.completedFuture(new BigDecimal(s)); + } catch (RuntimeException e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new BadRequestException("invalid amount: " + s)); + return failed; + } + } +} diff --git a/java8/src/test/java/folio/codinginterview/OptimalPortfolioScenarioTest.java b/java8/src/test/java/folio/codinginterview/OptimalPortfolioScenarioTest.java new file mode 100644 index 0000000..89836fa --- /dev/null +++ b/java8/src/test/java/folio/codinginterview/OptimalPortfolioScenarioTest.java @@ -0,0 +1,57 @@ +package folio.codinginterview; + +import folio.codinginterview.infrastructure.server.DummyServer; +import folio.codinginterview.presentation.PortfolioController; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OptimalPortfolioScenarioTest { + + private static void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { + assertTrue(expected.compareTo(actual) == 0, "expected " + expected + " but was " + actual); + } + + @Test + void 最適ポートフォリオを更新取得できる() throws Exception { + DummyServer server = DummyServer.defaultInstance(); + PortfolioController pc = server.portfolioController(); + + // Given: 最適ポートフォリオを Toyopa=0.20, Somy=0.80 に更新する + pc.updateOptimalPortfolio(new PortfolioController.UpdateOptimalPortfolioRequest(Arrays.asList( + new PortfolioController.PortfolioItemDto("Toyopa", "0.20"), + new PortfolioController.PortfolioItemDto("Somy", "0.80") + ))).get(); + + // When: 最適ポートフォリオを取得する + PortfolioController.GetOptimalPortfolioResponse first = pc.getOptimalPortfolio().get(); + Map firstMap = new HashMap<>(); + for (PortfolioController.PortfolioItemDto p : first.portfolios()) { + firstMap.put(p.symbol(), p.rate()); + } + + // Then: Toyopa=0.20, Somy=0.80 が返される + assertBigDecimalEquals(new BigDecimal("0.20"), new BigDecimal(firstMap.get("Toyopa"))); + assertBigDecimalEquals(new BigDecimal("0.80"), new BigDecimal(firstMap.get("Somy"))); + + // When: 最適ポートフォリオを Toyopa=0.40, Somy=0.60 に更新して再取得する + pc.updateOptimalPortfolio(new PortfolioController.UpdateOptimalPortfolioRequest(Arrays.asList( + new PortfolioController.PortfolioItemDto("Toyopa", "0.40"), + new PortfolioController.PortfolioItemDto("Somy", "0.60") + ))).get(); + PortfolioController.GetOptimalPortfolioResponse second = pc.getOptimalPortfolio().get(); + Map secondMap = new HashMap<>(); + for (PortfolioController.PortfolioItemDto p : second.portfolios()) { + secondMap.put(p.symbol(), p.rate()); + } + + // Then: Toyopa=0.40, Somy=0.60 が返される + assertBigDecimalEquals(new BigDecimal("0.40"), new BigDecimal(secondMap.get("Toyopa"))); + assertBigDecimalEquals(new BigDecimal("0.60"), new BigDecimal(secondMap.get("Somy"))); + } +} diff --git a/java8/src/test/java/folio/codinginterview/OrderScenarioTest.java b/java8/src/test/java/folio/codinginterview/OrderScenarioTest.java new file mode 100644 index 0000000..491dd58 --- /dev/null +++ b/java8/src/test/java/folio/codinginterview/OrderScenarioTest.java @@ -0,0 +1,136 @@ +package folio.codinginterview; + +import folio.codinginterview.infrastructure.server.DummyServer; +import folio.codinginterview.presentation.AssetController; +import folio.codinginterview.presentation.MarketPriceController; +import folio.codinginterview.presentation.OrderController; +import folio.codinginterview.presentation.PortfolioController; +import folio.codinginterview.presentation.PresentationException.BadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class OrderScenarioTest { + + private static void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { + assertTrue(expected.compareTo(actual) == 0, "expected " + expected + " but was " + actual); + } + + private final DummyServer server = DummyServer.defaultInstance(); + private final AssetController ac = server.assetController(); + private final PortfolioController pc = server.portfolioController(); + private final OrderController oc = server.orderController(); + private final MarketPriceController mp = server.marketPriceController(); + + @BeforeEach + void setUp() throws Exception { + // initialize market price and optimal portfolio + pc.updateOptimalPortfolio(new PortfolioController.UpdateOptimalPortfolioRequest(Arrays.asList( + new PortfolioController.PortfolioItemDto("Toyopa", "0.40"), + new PortfolioController.PortfolioItemDto("Somy", "0.60") + ))).get(); + mp.updateMarketPrice(new MarketPriceController.UpdateMarketPriceRequest(Arrays.asList( + new MarketPriceController.MarketPriceItemDto("Toyopa", "2.5"), + new MarketPriceController.MarketPriceItemDto("Somy", "3.0") + ))).get(); + } + + private Throwable unwrap(Throwable e) { + while (e instanceof ExecutionException || e instanceof CompletionException) { + if (e.getCause() == null) break; + e = e.getCause(); + } + return e; + } + + @Test + void 新規拠出追加拠出リバランスの一連の操作が正しく機能する() throws Exception { + String userId = UUID.randomUUID().toString(); + + // Given: 存在しないユーザーで資産を取得しようとする + Throwable notFound = null; + try { + ac.getAsset(new AssetController.GetAssetRequest(userId)).get(); + fail("expected exception"); + } catch (Exception e) { + notFound = unwrap(e); + } + // Then: BadRequestException が返される + assertTrue(notFound instanceof BadRequestException); + + // When: 最適ポートフォリオを Toyopa=40%, Somy=60% に更新する + pc.updateOptimalPortfolio(new PortfolioController.UpdateOptimalPortfolioRequest(Arrays.asList( + new PortfolioController.PortfolioItemDto("Toyopa", "0.40"), + new PortfolioController.PortfolioItemDto("Somy", "0.60") + ))).get(); + + // And: 新規拠出を 100,000 円で注文する + oc.newContributionOrder(new OrderController.NewContributionOrderRequest(userId, "100000")).get(); + + AssetController.GetAssetResponse asset1 = ac.getAsset(new AssetController.GetAssetRequest(userId)).get(); + List symbols1 = asset1.stocks().stream().map(AssetController.StockDto::symbol).collect(Collectors.toList()); + assertTrue(symbols1.contains("Toyopa") && symbols1.contains("Somy") && symbols1.size() == 2); + BigDecimal total1 = new BigDecimal(asset1.cashAmount()); + for (AssetController.StockDto e : asset1.stocks()) { + total1 = total1.add(new BigDecimal(e.evaluationAmount())); + } + assertTrue(total1.subtract(new BigDecimal(100000)).abs().compareTo(new BigDecimal(2)) <= 0); + + // Then: 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる + AssetController.StockDto asset1Toyopa = asset1.stocks().stream().filter(e -> e.symbol().equals("Toyopa")).findFirst().get(); + AssetController.StockDto asset1Somy = asset1.stocks().stream().filter(e -> e.symbol().equals("Somy")).findFirst().get(); + assertBigDecimalEquals(new BigDecimal("38000"), new BigDecimal(asset1Toyopa.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("57000"), new BigDecimal(asset1Somy.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("5000"), new BigDecimal(asset1.cashAmount())); + + // When: 追加拠出を 100,000 円で注文する + oc.additionalContributionOrder(new OrderController.AdditionalContributionOrderRequest(userId, "100000")).get(); + + // Then: 資産合計が約 200,000 円になる + AssetController.GetAssetResponse asset2 = ac.getAsset(new AssetController.GetAssetRequest(userId)).get(); + BigDecimal total2 = new BigDecimal(asset2.cashAmount()); + for (AssetController.StockDto e : asset2.stocks()) { + total2 = total2.add(new BigDecimal(e.evaluationAmount())); + } + assertTrue(total2.subtract(new BigDecimal(200000)).abs().compareTo(new BigDecimal(4)) <= 0); + + // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる + AssetController.StockDto asset2Toyopa = asset2.stocks().stream().filter(e -> e.symbol().equals("Toyopa")).findFirst().get(); + AssetController.StockDto asset2Somy = asset2.stocks().stream().filter(e -> e.symbol().equals("Somy")).findFirst().get(); + assertBigDecimalEquals(new BigDecimal("76000"), new BigDecimal(asset2Toyopa.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("114000"), new BigDecimal(asset2Somy.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("10000"), new BigDecimal(asset2.cashAmount())); + + // When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする + pc.updateOptimalPortfolio(new PortfolioController.UpdateOptimalPortfolioRequest(Arrays.asList( + new PortfolioController.PortfolioItemDto("Toyopa", "0.10"), + new PortfolioController.PortfolioItemDto("Somy", "0.90") + ))).get(); + oc.rebalanceOrder(new OrderController.RebalanceOrderRequest(userId)).get(); + + // Then: リバランス後も資産合計がほぼ変わらない + AssetController.GetAssetResponse asset3 = ac.getAsset(new AssetController.GetAssetRequest(userId)).get(); + BigDecimal total3 = new BigDecimal(asset3.cashAmount()); + for (AssetController.StockDto e : asset3.stocks()) { + total3 = total3.add(new BigDecimal(e.evaluationAmount())); + } + assertTrue(total3.subtract(total2).abs().compareTo(new BigDecimal(4)) <= 0); + + // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる + AssetController.StockDto asset3Toyopa = asset3.stocks().stream().filter(e -> e.symbol().equals("Toyopa")).findFirst().get(); + AssetController.StockDto asset3Somy = asset3.stocks().stream().filter(e -> e.symbol().equals("Somy")).findFirst().get(); + assertBigDecimalEquals(new BigDecimal("19000"), new BigDecimal(asset3Toyopa.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("171000"), new BigDecimal(asset3Somy.evaluationAmount())); + assertBigDecimalEquals(new BigDecimal("10000"), new BigDecimal(asset3.cashAmount())); + } +} diff --git a/php/.gitignore b/php/.gitignore new file mode 100644 index 0000000..0794651 --- /dev/null +++ b/php/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +composer.lock +.phpunit.result.cache diff --git a/php/README.md b/php/README.md new file mode 100644 index 0000000..aaa429c --- /dev/null +++ b/php/README.md @@ -0,0 +1,68 @@ +# サンプルラップサービス + +## 開発 + +- PHP 8.1+ +- ext-bcmath + +```shell +# 準備 +git init && git add . && git commit -m init + +# setup +composer install + +# test +vendor/bin/phpunit +``` + +## サービス概要 + +このアプリケーションはロボアドバイザーサービスのバックエンドです。 + +### 株と評価額 + +- 株には株数(qty)があります(例: 1株、2株) +- 各株には1株あたりの市場価格があります(例: 1株あたり100円) +- 例: 顧客が5株保有している場合、評価額は `5株 × 100円 = 500円` となります + +### ロボアドバイザーサービス + +- **顧客の口座** + - 新規拠出を行うと、口座がすぐに開きます + - 口座の中で資産を管理することになります +- **顧客の資産** + - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します + - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円をいくつかの株で保有する + - 株は価格で保持するのではなく、株数で保持します + - そのため、市場価格に応じて評価額は変わることになります +- **最適ポートフォリオ** + - サービスが管理する、株の評価額ベースの構成比率 + - 例: A株を評価額の30%B株を評価額の70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30% B株95万円*70% になるように努める + - 購入時・売却時・リバランス時には、売買後の資産比率が現在の最適ポートフォリオに近づく形での売買を実施します +- **株の売買** + - 本アプリケーションでは、注文APIを叩くと即時必要な株の売買が成立し資産に反映出来るものとします +- 用語 + - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 + - 全売却注文: 運用中の株を全て売却すること。 + - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + +## 確認観点 + +- 成果を出すこと +- 成果物についての理解・責任を持つこと + +## 課題 + +以下の課題をAIを用いて、あなた自身の言葉・実装で回答してください。 + +1. API/アーキテクチャについてAIと協力しながら自分の言葉で説明してください +2. テストが全て通るようにしてください + - まずは現状のテストを走らせて、どのような内容で落ちているかを説明してください + - また、実装バグがあるため、テストコードは一切変更せず、AIと協力しながら実装を修正してください + - その上で、修正内容について説明をしてください +3. 全売却APIを実装してください + - 全売却後の現金の取り扱いに関しての方針を決めてください + - ストレッチ: 売却後の現金は銀行APIへ連携 +4. (部分売却APIを実装してください) diff --git a/php/composer.json b/php/composer.json new file mode 100644 index 0000000..5882654 --- /dev/null +++ b/php/composer.json @@ -0,0 +1,22 @@ +{ + "name": "folio/coding-interview-backend-php", + "description": "PHP implementation of the coding interview backend", + "type": "project", + "require": { + "php": ">=8.1", + "ext-bcmath": "*" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "autoload": { + "psr-4": { + "Folio\\CodingInterview\\": "src/CodingInterview/" + } + }, + "autoload-dev": { + "psr-4": { + "Folio\\CodingInterview\\Tests\\": "tests/" + } + } +} diff --git a/php/phpunit.xml b/php/phpunit.xml new file mode 100644 index 0000000..b5b0572 --- /dev/null +++ b/php/phpunit.xml @@ -0,0 +1,10 @@ + + + + + tests + + + diff --git a/php/src/CodingInterview/Application/Repository/AccountRepository.php b/php/src/CodingInterview/Application/Repository/AccountRepository.php new file mode 100644 index 0000000..1627cf5 --- /dev/null +++ b/php/src/CodingInterview/Application/Repository/AccountRepository.php @@ -0,0 +1,20 @@ + keyed by StockSymbol->value */ + public function all(): array; + + /** @param array $prices */ + public function update(array $prices): void; +} diff --git a/php/src/CodingInterview/Application/Repository/PortfolioRepository.php b/php/src/CodingInterview/Application/Repository/PortfolioRepository.php new file mode 100644 index 0000000..a6847dd --- /dev/null +++ b/php/src/CodingInterview/Application/Repository/PortfolioRepository.php @@ -0,0 +1,17 @@ + $prices */ + public static function evaluateStock(Stock $stock, array $prices): BigDecimal + { + if (!array_key_exists($stock->symbol->value, $prices)) { + throw new \LogicException("missing price for {$stock->symbol->value}"); + } + return $stock->qty->mul($prices[$stock->symbol->value]); + } + + /** @param array $prices */ + public static function totalValuation(Account $account, array $prices): BigDecimal + { + $sum = BigDecimal::zero(); + foreach ($account->stocks as $e) { + $sum = $sum->add(self::evaluateStock($e, $prices)); + } + return $sum->add($account->cash); + } +} diff --git a/php/src/CodingInterview/Application/Service/PortfolioService.php b/php/src/CodingInterview/Application/Service/PortfolioService.php new file mode 100644 index 0000000..cdd0563 --- /dev/null +++ b/php/src/CodingInterview/Application/Service/PortfolioService.php @@ -0,0 +1,125 @@ +setScaleDown(2); + } + + private static function floor0(BigDecimal $x): BigDecimal + { + return $x->setScaleDown(0); + } + + /** @param array $prices */ + private static function priceOf(array $prices, StockSymbol $symbol): BigDecimal + { + if (!array_key_exists($symbol->value, $prices)) { + throw new \LogicException("missing price for {$symbol->value}"); + } + return $prices[$symbol->value]; + } + + /** + * Allocate a brand-new account given a contribution amount. + * @param array $prices + */ + public static function allocateNew(BigDecimal $amount, Portfolio $portfolio, array $prices): Account + { + $cashFromRate = self::floor0($amount->mul(AppConstants::cashRate())); + $investable = $amount->sub($cashFromRate); + $stocks = []; + foreach ($portfolio->items as $item) { + $price = self::priceOf($prices, $item->symbol); + $qty = self::floor2($investable->mul($item->rate)->div($price)); + $stocks[] = new Stock($item->symbol, $qty); + } + $usedForStocks = BigDecimal::zero(); + foreach ($stocks as $e) { + $usedForStocks = $usedForStocks->add($e->qty->mul(self::priceOf($prices, $e->symbol))); + } + $residual = $investable->sub($usedForStocks); + return new Account($cashFromRate->add($residual), $stocks); + } + + /** + * Additional contribution: only buy (no sell). Residual is kept in cash. + * @param array $prices + */ + public static function allocateAdditional( + Account $account, + BigDecimal $amount, + Portfolio $portfolio, + array $prices, + ): Account { + $totalAfter = AssetService::totalValuation($account, $prices)->add($amount); + $targetCash = self::floor0($totalAfter->mul(AppConstants::cashRate())); + $investable = $totalAfter->sub($targetCash); + + $currentQty = []; + foreach ($account->stocks as $e) { + $currentQty[$e->symbol->value] = $e->qty; + } + + $portfolioSymbols = []; + foreach ($portfolio->items as $i) { + $portfolioSymbols[$i->symbol->value] = true; + } + $newPortfolioStocks = []; + foreach ($portfolio->items as $item) { + $price = self::priceOf($prices, $item->symbol); + $targetQty = self::floor2($investable->mul($item->rate)->div($price)); + $current = $currentQty[$item->symbol->value] ?? BigDecimal::zero(); + $finalQty = $targetQty->gt($current) ? $targetQty : $current; + $newPortfolioStocks[] = new Stock($item->symbol, $finalQty); + } + $preservedStocks = []; + foreach ($account->stocks as $e) { + if (!isset($portfolioSymbols[$e->symbol->value])) { + $preservedStocks[] = $e; + } + } + $allStocks = array_merge($newPortfolioStocks, $preservedStocks); + + $finalValuation = BigDecimal::zero(); + foreach ($allStocks as $e) { + $finalValuation = $finalValuation->add($e->qty->mul(self::priceOf($prices, $e->symbol))); + } + $finalCash = $totalAfter->sub($finalValuation); + return new Account($finalCash, $allStocks); + } + + /** + * Rebalance: re-allocate qty per portfolio target (buy and sell). + * @param array $prices + */ + public static function rebalance(Account $account, Portfolio $portfolio, array $prices): Account + { + // XXX this implementation might not be correct + $investable = AssetService::totalValuation($account, $prices); + $newStocks = []; + foreach ($portfolio->items as $item) { + $price = self::priceOf($prices, $item->symbol); + $qty = self::floor2($investable->mul($item->rate)->div($price)); + $newStocks[] = new Stock($item->symbol, $qty); + } + $finalValuation = BigDecimal::zero(); + foreach ($newStocks as $e) { + $finalValuation = $finalValuation->add($e->qty->mul(self::priceOf($prices, $e->symbol))); + } + $finalCash = $investable->sub($finalValuation); + return new Account($finalCash, $newStocks); + } +} diff --git a/php/src/CodingInterview/Application/Usecase/Asset/GetAssetUsecase.php b/php/src/CodingInterview/Application/Usecase/Asset/GetAssetUsecase.php new file mode 100644 index 0000000..ce2eea7 --- /dev/null +++ b/php/src/CodingInterview/Application/Usecase/Asset/GetAssetUsecase.php @@ -0,0 +1,56 @@ +accountRepository->find($input->userId); + if ($account === null) { + throw new GetAssetUsecaseUserNotFoundException(); + } + $prices = $this->marketPriceRepository->all(); + $stocks = []; + foreach ($account->stocks as $e) { + $stocks[] = new GetAssetStockOutput($e->symbol, AssetService::evaluateStock($e, $prices)); + } + return new GetAssetUsecaseOutput($account->cash, $stocks); + } +} diff --git a/php/src/CodingInterview/Application/Usecase/Asset/GetAssetUsecaseUserNotFoundException.php b/php/src/CodingInterview/Application/Usecase/Asset/GetAssetUsecaseUserNotFoundException.php new file mode 100644 index 0000000..4d7ac9a --- /dev/null +++ b/php/src/CodingInterview/Application/Usecase/Asset/GetAssetUsecaseUserNotFoundException.php @@ -0,0 +1,13 @@ +items as $i) { + $prices[$i->symbol->value] = $i->marketPrice; + } + $this->marketPriceRepository->update($prices); + } +} diff --git a/php/src/CodingInterview/Application/Usecase/Order/AdditionalBuyOrderUsecase.php b/php/src/CodingInterview/Application/Usecase/Order/AdditionalBuyOrderUsecase.php new file mode 100644 index 0000000..3cd6c7e --- /dev/null +++ b/php/src/CodingInterview/Application/Usecase/Order/AdditionalBuyOrderUsecase.php @@ -0,0 +1,55 @@ +amount->lt(AppConstants::minOperationAmount())) { + throw new AdditionalBuyOrderAmountTooSmallException(); + } + $account = $this->accountRepository->find($input->userId); + if ($account === null) { + throw new AdditionalBuyOrderUserNotFoundException(); + } + $portfolio = $this->portfolioRepository->get(); + $prices = $this->marketPriceRepository->all(); + $updated = PortfolioService::allocateAdditional($account, $input->amount, $portfolio, $prices); + $this->accountRepository->upsert($input->userId, $updated); + } +} diff --git a/php/src/CodingInterview/Application/Usecase/Order/NewContributionOrderUsecase.php b/php/src/CodingInterview/Application/Usecase/Order/NewContributionOrderUsecase.php new file mode 100644 index 0000000..17cd56b --- /dev/null +++ b/php/src/CodingInterview/Application/Usecase/Order/NewContributionOrderUsecase.php @@ -0,0 +1,54 @@ +amount->lt(AppConstants::minOperationAmount())) { + throw new NewContributionOrderAmountTooSmallException(); + } + if ($this->accountRepository->exists($input->userId)) { + throw new NewContributionOrderUserAlreadyExistsException(); + } + $portfolio = $this->portfolioRepository->get(); + $prices = $this->marketPriceRepository->all(); + $account = PortfolioService::allocateNew($input->amount, $portfolio, $prices); + $this->accountRepository->upsert($input->userId, $account); + } +} diff --git a/php/src/CodingInterview/Application/Usecase/Order/RebalanceOrderUsecase.php b/php/src/CodingInterview/Application/Usecase/Order/RebalanceOrderUsecase.php new file mode 100644 index 0000000..d40448c --- /dev/null +++ b/php/src/CodingInterview/Application/Usecase/Order/RebalanceOrderUsecase.php @@ -0,0 +1,42 @@ +accountRepository->find($input->userId); + if ($account === null) { + throw new RebalanceOrderUserNotFoundException(); + } + $portfolio = $this->portfolioRepository->get(); + $prices = $this->marketPriceRepository->all(); + $updated = PortfolioService::rebalance($account, $portfolio, $prices); + $this->accountRepository->upsert($input->userId, $updated); + } +} diff --git a/php/src/CodingInterview/Application/Usecase/Portfolio/GetLatestPortfolioUsecase.php b/php/src/CodingInterview/Application/Usecase/Portfolio/GetLatestPortfolioUsecase.php new file mode 100644 index 0000000..220ecec --- /dev/null +++ b/php/src/CodingInterview/Application/Usecase/Portfolio/GetLatestPortfolioUsecase.php @@ -0,0 +1,38 @@ +portfolioRepository->get(); + $items = []; + foreach ($p->items as $i) { + $items[] = new GetLatestPortfolioItemOutput($i->symbol, $i->rate); + } + return new GetLatestPortfolioUsecaseOutput($items); + } +} diff --git a/php/src/CodingInterview/Application/Usecase/Portfolio/UpdatePortfolioUsecase.php b/php/src/CodingInterview/Application/Usecase/Portfolio/UpdatePortfolioUsecase.php new file mode 100644 index 0000000..3809eae --- /dev/null +++ b/php/src/CodingInterview/Application/Usecase/Portfolio/UpdatePortfolioUsecase.php @@ -0,0 +1,48 @@ +items as $i) { + $items[] = new PortfolioItem($i->symbol, $i->rate); + } + $portfolio = new Portfolio($items); + } catch (\Throwable $e) { + throw new UpdatePortfolioInvalidPortfolioException($e->getMessage(), 0, $e); + } + $this->portfolioRepository->update($portfolio); + } +} diff --git a/php/src/CodingInterview/Domain/Account.php b/php/src/CodingInterview/Domain/Account.php new file mode 100644 index 0000000..e2392f9 --- /dev/null +++ b/php/src/CodingInterview/Domain/Account.php @@ -0,0 +1,15 @@ + keyed by StockSymbol->value */ + public static function initialPrices(): array + { + return [ + StockSymbol::Toyopa->value => new BigDecimal('4.2135'), + StockSymbol::Somy->value => new BigDecimal('1.2345'), + ]; + } + + public static function initialPortfolio(): Portfolio + { + return new Portfolio([ + new PortfolioItem(StockSymbol::Toyopa, new BigDecimal('0.40')), + new PortfolioItem(StockSymbol::Somy, new BigDecimal('0.60')), + ]); + } +} diff --git a/php/src/CodingInterview/Domain/BigDecimal.php b/php/src/CodingInterview/Domain/BigDecimal.php new file mode 100644 index 0000000..b16fa37 --- /dev/null +++ b/php/src/CodingInterview/Domain/BigDecimal.php @@ -0,0 +1,98 @@ +value = $value; + } + + public static function of(string|int|float|BigDecimal $v): BigDecimal + { + return $v instanceof BigDecimal ? $v : new BigDecimal($v); + } + + public static function zero(): BigDecimal + { + return new BigDecimal('0'); + } + + public function add(BigDecimal $other): BigDecimal + { + return new BigDecimal(bcadd($this->value, $other->value, self::SCALE)); + } + + public function sub(BigDecimal $other): BigDecimal + { + return new BigDecimal(bcsub($this->value, $other->value, self::SCALE)); + } + + public function mul(BigDecimal $other): BigDecimal + { + return new BigDecimal(bcmul($this->value, $other->value, self::SCALE)); + } + + public function div(BigDecimal $other): BigDecimal + { + return new BigDecimal(bcdiv($this->value, $other->value, self::SCALE)); + } + + public function abs(): BigDecimal + { + return new BigDecimal(ltrim($this->value, '-') === '' ? '0' : ltrim($this->value, '-')); + } + + public function compare(BigDecimal $other): int + { + return bccomp($this->value, $other->value, self::SCALE); + } + + public function gt(BigDecimal $other): bool { return $this->compare($other) > 0; } + public function lt(BigDecimal $other): bool { return $this->compare($other) < 0; } + public function eq(BigDecimal $other): bool { return $this->compare($other) === 0; } + + /** Truncate toward zero (floor for non-negative) at given scale. */ + public function setScaleDown(int $scale): BigDecimal + { + return new BigDecimal(bcadd($this->value, '0', $scale)); + } + + public function toString(): string + { + return $this->stripTrailingZeros($this->value); + } + + public function rawString(): string + { + return $this->value; + } + + private function stripTrailingZeros(string $s): string + { + if (!str_contains($s, '.')) { + return $s; + } + $s = rtrim($s, '0'); + $s = rtrim($s, '.'); + return $s === '' || $s === '-' ? '0' : $s; + } + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/php/src/CodingInterview/Domain/Portfolio.php b/php/src/CodingInterview/Domain/Portfolio.php new file mode 100644 index 0000000..dba92c0 --- /dev/null +++ b/php/src/CodingInterview/Domain/Portfolio.php @@ -0,0 +1,28 @@ +add($i->rate); + $symbols[] = $i->symbol->value; + } + if (!$sum->eq(BigDecimal::of(1))) { + throw new \InvalidArgumentException("portfolio rates must sum to 1, got {$sum}"); + } + if (count(array_unique($symbols)) !== count($symbols)) { + throw new \InvalidArgumentException('portfolio must not have duplicate symbols'); + } + } +} diff --git a/php/src/CodingInterview/Domain/PortfolioItem.php b/php/src/CodingInterview/Domain/PortfolioItem.php new file mode 100644 index 0000000..18db8ae --- /dev/null +++ b/php/src/CodingInterview/Domain/PortfolioItem.php @@ -0,0 +1,14 @@ + self::Toyopa, + 'Somy' => self::Somy, + default => null, + }; + } +} diff --git a/php/src/CodingInterview/Domain/UserId.php b/php/src/CodingInterview/Domain/UserId.php new file mode 100644 index 0000000..5ca9aa8 --- /dev/null +++ b/php/src/CodingInterview/Domain/UserId.php @@ -0,0 +1,15 @@ + */ + private array $store = []; + + public function find(UserId $userId): ?Account + { + return $this->store[$userId->value] ?? null; + } + + public function upsert(UserId $userId, Account $account): void + { + $this->store[$userId->value] = $account; + } + + public function exists(UserId $userId): bool + { + return array_key_exists($userId->value, $this->store); + } +} diff --git a/php/src/CodingInterview/Infrastructure/Repository/MarketPriceRepositoryImpl.php b/php/src/CodingInterview/Infrastructure/Repository/MarketPriceRepositoryImpl.php new file mode 100644 index 0000000..cd67c28 --- /dev/null +++ b/php/src/CodingInterview/Infrastructure/Repository/MarketPriceRepositoryImpl.php @@ -0,0 +1,30 @@ + */ + private array $prices; + + public function __construct() + { + $this->prices = AppConstants::initialPrices(); + } + + public function all(): array + { + return $this->prices; + } + + public function update(array $prices): void + { + $this->prices = $prices; + } +} diff --git a/php/src/CodingInterview/Infrastructure/Repository/PortfolioRepositoryImpl.php b/php/src/CodingInterview/Infrastructure/Repository/PortfolioRepositoryImpl.php new file mode 100644 index 0000000..7827270 --- /dev/null +++ b/php/src/CodingInterview/Infrastructure/Repository/PortfolioRepositoryImpl.php @@ -0,0 +1,29 @@ +portfolio = AppConstants::initialPortfolio(); + } + + public function get(): Portfolio + { + return $this->portfolio; + } + + public function update(Portfolio $portfolio): void + { + $this->portfolio = $portfolio; + } +} diff --git a/php/src/CodingInterview/Infrastructure/Server/DummyServer.php b/php/src/CodingInterview/Infrastructure/Server/DummyServer.php new file mode 100644 index 0000000..9700b3b --- /dev/null +++ b/php/src/CodingInterview/Infrastructure/Server/DummyServer.php @@ -0,0 +1,58 @@ +parseUserId($req->userId); + try { + $out = $this->getAssetUsecase->run(new GetAssetUsecaseInput($uid)); + } catch (GetAssetUsecaseUserNotFoundException $e) { + throw new BadRequestException('user not found'); + } + $stocks = []; + foreach ($out->stocks as $e) { + $stocks[] = new GetAssetStockDto($e->symbol->value, $e->evaluationAmount->toString()); + } + return new GetAssetResponse($out->cashAmount->toString(), $stocks); + } +} diff --git a/php/src/CodingInterview/Presentation/BadRequestException.php b/php/src/CodingInterview/Presentation/BadRequestException.php new file mode 100644 index 0000000..84654ca --- /dev/null +++ b/php/src/CodingInterview/Presentation/BadRequestException.php @@ -0,0 +1,9 @@ +market_prices as $dto) { + $sym = StockSymbol::fromStringOrNull($dto->symbol); + if ($sym === null) { + throw new BadRequestException("unknown symbol: {$dto->symbol}"); + } + try { + $price = new BigDecimal($dto->market_price); + } catch (\Throwable $e) { + throw new BadRequestException("invalid market_price: {$dto->market_price}"); + } + $items[] = new UpdateMarketPriceItemInput($sym, $price); + } + $this->updateMarketPriceUsecase->run(new UpdateMarketPriceUsecaseInput($items)); + } +} diff --git a/php/src/CodingInterview/Presentation/OrderController.php b/php/src/CodingInterview/Presentation/OrderController.php new file mode 100644 index 0000000..c8c8427 --- /dev/null +++ b/php/src/CodingInterview/Presentation/OrderController.php @@ -0,0 +1,85 @@ +parseUserId($req->userId); + $amt = $this->parseAmount($req->amount); + try { + $this->newContributionOrderUsecase->run(new NewContributionOrderUsecaseInput($uid, $amt)); + } catch (NewContributionOrderUserAlreadyExistsException $e) { + throw new BadRequestException('user already has account'); + } catch (NewContributionOrderAmountTooSmallException $e) { + throw new BadRequestException('amount is too small'); + } + } + + public function additionalContributionOrder(AdditionalContributionOrderRequest $req): void + { + $uid = $this->parseUserId($req->userId); + $amt = $this->parseAmount($req->amount); + try { + $this->additionalBuyOrderUsecase->run(new AdditionalBuyOrderUsecaseInput($uid, $amt)); + } catch (AdditionalBuyOrderUserNotFoundException $e) { + throw new BadRequestException('user has no live account'); + } catch (AdditionalBuyOrderAmountTooSmallException $e) { + throw new BadRequestException('amount is too small'); + } + } + + public function rebalanceOrder(RebalanceOrderRequest $req): void + { + $uid = $this->parseUserId($req->userId); + try { + $this->rebalanceOrderUsecase->run(new RebalanceOrderUsecaseInput($uid)); + } catch (RebalanceOrderUserNotFoundException $e) { + throw new BadRequestException('user has no live account'); + } + } +} diff --git a/php/src/CodingInterview/Presentation/PortfolioController.php b/php/src/CodingInterview/Presentation/PortfolioController.php new file mode 100644 index 0000000..460aabf --- /dev/null +++ b/php/src/CodingInterview/Presentation/PortfolioController.php @@ -0,0 +1,73 @@ +getLatestPortfolioUsecase->run(); + $items = []; + foreach ($out->items as $i) { + $items[] = new PortfolioItemDto($i->symbol->value, $i->rate->toString()); + } + return new GetOptimalPortfolioResponse($items); + } + + public function updateOptimalPortfolio(UpdateOptimalPortfolioRequest $req): void + { + $items = []; + foreach ($req->portfolios as $dto) { + $sym = StockSymbol::fromStringOrNull($dto->symbol); + if ($sym === null) { + throw new BadRequestException("unknown symbol: {$dto->symbol}"); + } + try { + $rate = new BigDecimal($dto->rate); + } catch (\Throwable $e) { + throw new BadRequestException("invalid rate: {$dto->rate}"); + } + $items[] = new UpdatePortfolioItemInput($sym, $rate); + } + try { + $this->updatePortfolioUsecase->run(new UpdatePortfolioUsecaseInput($items)); + } catch (UpdatePortfolioInvalidPortfolioException $e) { + throw new BadRequestException($e->getMessage()); + } + } +} diff --git a/php/src/CodingInterview/Presentation/PresentationPreparation.php b/php/src/CodingInterview/Presentation/PresentationPreparation.php new file mode 100644 index 0000000..71143d2 --- /dev/null +++ b/php/src/CodingInterview/Presentation/PresentationPreparation.php @@ -0,0 +1,29 @@ +getMessage()); + } + } + + protected function parseAmount(string $s): BigDecimal + { + try { + return new BigDecimal($s); + } catch (\Throwable $e) { + throw new BadRequestException("invalid amount: {$s}"); + } + } +} diff --git a/php/tests/OptimalPortfolioScenarioTest.php b/php/tests/OptimalPortfolioScenarioTest.php new file mode 100644 index 0000000..efab5b8 --- /dev/null +++ b/php/tests/OptimalPortfolioScenarioTest.php @@ -0,0 +1,45 @@ +portfolioController; + + $pc->updateOptimalPortfolio(new UpdateOptimalPortfolioRequest([ + new PortfolioItemDto('Toyopa', '0.20'), + new PortfolioItemDto('Somy', '0.80'), + ])); + + $first = $pc->getOptimalPortfolio(); + $firstMap = []; + foreach ($first->portfolios as $p) { + $firstMap[$p->symbol] = $p->rate; + } + $this->assertSame('0.2', (string)(new BigDecimal($firstMap['Toyopa']))); + $this->assertSame('0.8', (string)(new BigDecimal($firstMap['Somy']))); + + $pc->updateOptimalPortfolio(new UpdateOptimalPortfolioRequest([ + new PortfolioItemDto('Toyopa', '0.40'), + new PortfolioItemDto('Somy', '0.60'), + ])); + $second = $pc->getOptimalPortfolio(); + $secondMap = []; + foreach ($second->portfolios as $p) { + $secondMap[$p->symbol] = $p->rate; + } + $this->assertSame('0.4', (string)(new BigDecimal($secondMap['Toyopa']))); + $this->assertSame('0.6', (string)(new BigDecimal($secondMap['Somy']))); + } +} diff --git a/php/tests/OrderScenarioTest.php b/php/tests/OrderScenarioTest.php new file mode 100644 index 0000000..369795b --- /dev/null +++ b/php/tests/OrderScenarioTest.php @@ -0,0 +1,128 @@ +assetController; + $pc = $server->portfolioController; + $oc = $server->orderController; + $mp = $server->marketPriceController; + + // initialize market price and optimal portfolio + $pc->updateOptimalPortfolio(new UpdateOptimalPortfolioRequest([ + new PortfolioItemDto('Toyopa', '0.40'), + new PortfolioItemDto('Somy', '0.60'), + ])); + $mp->updateMarketPrice(new UpdateMarketPriceRequest([ + new MarketPriceItemDto('Toyopa', '2.5'), + new MarketPriceItemDto('Somy', '3.0'), + ])); + + $userId = bin2hex(random_bytes(8)); + + // Given: 存在しないユーザーで資産を取得しようとする + try { + $ac->getAsset(new GetAssetRequest($userId)); + $this->fail('expected BadRequestException for unknown user, but no exception was thrown'); + } catch (BadRequestException $e) { + // Then: BadRequestException が返される + } + + // When: 最適ポートフォリオを Toyopa=40%, Somy=60% に更新する + $pc->updateOptimalPortfolio(new UpdateOptimalPortfolioRequest([ + new PortfolioItemDto('Toyopa', '0.40'), + new PortfolioItemDto('Somy', '0.60'), + ])); + + // And: 新規拠出を 100,000 円で注文する + $oc->newContributionOrder(new NewContributionOrderRequest($userId, '100000')); + + $asset1 = $ac->getAsset(new GetAssetRequest($userId)); + $symbols = array_map(fn($e) => $e->symbol, $asset1->stocks); + sort($symbols); + $this->assertSame(['Somy', 'Toyopa'], $symbols); + + $total1 = new BigDecimal($asset1->cashAmount); + foreach ($asset1->stocks as $e) { + $total1 = $total1->add(new BigDecimal($e->evaluationAmount)); + } + $this->assertLessThanOrEqual(0, $total1->sub(new BigDecimal('100000'))->abs()->compare(new BigDecimal('2')), "total1={$total1} (expected ≈100000)"); + + // Then: 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる + $toyopa1 = $this->findStock($asset1->stocks, 'Toyopa'); + $somy1 = $this->findStock($asset1->stocks, 'Somy'); + $this->assertSame('38000', (string)(new BigDecimal($toyopa1->evaluationAmount))); + $this->assertSame('57000', (string)(new BigDecimal($somy1->evaluationAmount))); + $this->assertSame('5000', (string)(new BigDecimal($asset1->cashAmount))); + + // When: 追加拠出を 100,000 円で注文する + $oc->additionalContributionOrder(new AdditionalContributionOrderRequest($userId, '100000')); + + // Then: 資産合計が約 200,000 円になる + $asset2 = $ac->getAsset(new GetAssetRequest($userId)); + $total2 = new BigDecimal($asset2->cashAmount); + foreach ($asset2->stocks as $e) { + $total2 = $total2->add(new BigDecimal($e->evaluationAmount)); + } + $this->assertLessThanOrEqual(0, $total2->sub(new BigDecimal('200000'))->abs()->compare(new BigDecimal('4')), "total2={$total2} (expected ≈200000)"); + + // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる + $toyopa2 = $this->findStock($asset2->stocks, 'Toyopa'); + $somy2 = $this->findStock($asset2->stocks, 'Somy'); + $this->assertSame('76000', (string)(new BigDecimal($toyopa2->evaluationAmount))); + $this->assertSame('114000', (string)(new BigDecimal($somy2->evaluationAmount))); + $this->assertSame('10000', (string)(new BigDecimal($asset2->cashAmount))); + + // When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする + $pc->updateOptimalPortfolio(new UpdateOptimalPortfolioRequest([ + new PortfolioItemDto('Toyopa', '0.10'), + new PortfolioItemDto('Somy', '0.90'), + ])); + $oc->rebalanceOrder(new RebalanceOrderRequest($userId)); + + // Then: リバランス後も資産合計がほぼ変わらない + $asset3 = $ac->getAsset(new GetAssetRequest($userId)); + $total3 = new BigDecimal($asset3->cashAmount); + foreach ($asset3->stocks as $e) { + $total3 = $total3->add(new BigDecimal($e->evaluationAmount)); + } + $this->assertLessThanOrEqual(0, $total3->sub($total2)->abs()->compare(new BigDecimal('4')), "total3={$total3}, total2={$total2} (expected ≈ equal)"); + + // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる + $toyopa3 = $this->findStock($asset3->stocks, 'Toyopa'); + $somy3 = $this->findStock($asset3->stocks, 'Somy'); + $this->assertSame('19000', (string)(new BigDecimal($toyopa3->evaluationAmount))); + $this->assertSame('171000', (string)(new BigDecimal($somy3->evaluationAmount))); + $this->assertSame('10000', (string)(new BigDecimal($asset3->cashAmount))); + } + + /** @param array $stocks */ + private function findStock(array $stocks, string $symbol) + { + foreach ($stocks as $e) { + if ($e->symbol === $symbol) { + return $e; + } + } + $this->fail("stock not found: {$symbol}"); + } +} diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000..10b3910 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,179 @@ +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/4d602a24bc5e4bbc2b8cedf08d4e982a80a7dfea/Python.gitignore + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + + diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..9d1bee2 --- /dev/null +++ b/python/README.md @@ -0,0 +1,69 @@ +# サンプルラップサービス + +## 開発 + +- Python 3.9+ + +```shell +# 準備 +git init && git add . && git commit -m init + +# setup +python3 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" + +# test +pytest -v +``` + +## サービス概要 + +このアプリケーションはロボアドバイザーサービスのバックエンドです。 + +### 株と評価額 + +- 株には株数(qty)があります(例: 1株、2株) +- 各株には1株あたりの市場価格があります(例: 1株あたり100円) +- 例: 顧客が5株保有している場合、評価額は `5株 × 100円 = 500円` となります + +### ロボアドバイザーサービス + +- **顧客の口座** + - 新規拠出を行うと、口座がすぐに開きます + - 口座の中で資産を管理することになります +- **顧客の資産** + - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します + - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円をいくつかの株で保有する + - 株は価格で保持するのではなく、株数で保持します + - そのため、市場価格に応じて評価額は変わることになります +- **最適ポートフォリオ** + - サービスが管理する、株の評価額ベースの構成比率 + - 例: A株を評価額の30%B株を評価額の70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30% B株95万円*70% になるように努める + - 購入時・売却時・リバランス時には、売買後の資産比率が現在の最適ポートフォリオに近づく形での売買を実施します +- **株の売買** + - 本アプリケーションでは、注文APIを叩くと即時必要な株の売買が成立し資産に反映出来るものとします +- 用語 + - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 + - 全売却注文: 運用中の株を全て売却すること。 + - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + +## 確認観点 + +- 成果を出すこと +- 成果物についての理解・責任を持つこと + +## 課題 + +以下の課題をAIを用いて、あなた自身の言葉・実装で回答してください。 + +1. API/アーキテクチャについてAIと協力しながら自分の言葉で説明してください +2. テストが全て通るようにしてください + - まずは現状のテストを走らせて、どのような内容で落ちているかを説明してください + - また、実装バグがあるため、テストコードは一切変更せず、AIと協力しながら実装を修正してください + - その上で、修正内容について説明をしてください +3. 全売却APIを実装してください + - 全売却後の現金の取り扱いに関しての方針を決めてください + - ストレッチ: 売却後の現金は銀行APIへ連携 +4. (部分売却APIを実装してください) diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..97d756f --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "coding-interview" +version = "0.1.0" +requires-python = ">=3.9" + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/python/src/coding_interview/__init__.py b/python/src/coding_interview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/application/__init__.py b/python/src/coding_interview/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/application/repository/__init__.py b/python/src/coding_interview/application/repository/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/application/repository/account_repository.py b/python/src/coding_interview/application/repository/account_repository.py new file mode 100644 index 0000000..fd9d10a --- /dev/null +++ b/python/src/coding_interview/application/repository/account_repository.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Protocol + +from coding_interview.domain.stock import Account +from coding_interview.domain.user_id import UserId + + +class AccountRepository(Protocol): + """口座管理リポジトリ。""" + + def find(self, user_id: UserId) -> Account | None: ... + def upsert(self, user_id: UserId, account: Account) -> None: ... + def exists(self, user_id: UserId) -> bool: ... diff --git a/python/src/coding_interview/application/repository/market_price_repository.py b/python/src/coding_interview/application/repository/market_price_repository.py new file mode 100644 index 0000000..6b28b90 --- /dev/null +++ b/python/src/coding_interview/application/repository/market_price_repository.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Protocol + +from coding_interview.domain.stock_symbol import StockSymbol + + +class MarketPriceRepository(Protocol): + """市場価格リポジトリ。""" + + def all(self) -> dict[StockSymbol, Decimal]: ... + def update(self, prices: dict[StockSymbol, Decimal]) -> None: ... diff --git a/python/src/coding_interview/application/repository/portfolio_repository.py b/python/src/coding_interview/application/repository/portfolio_repository.py new file mode 100644 index 0000000..5bd0b3d --- /dev/null +++ b/python/src/coding_interview/application/repository/portfolio_repository.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Protocol + +from coding_interview.domain.stock import Portfolio + + +class PortfolioRepository(Protocol): + """最適ポートフォリオリポジトリ。""" + + def get(self) -> Portfolio: ... + def update(self, portfolio: Portfolio) -> None: ... diff --git a/python/src/coding_interview/application/service/__init__.py b/python/src/coding_interview/application/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/application/service/asset_service.py b/python/src/coding_interview/application/service/asset_service.py new file mode 100644 index 0000000..2e13a5f --- /dev/null +++ b/python/src/coding_interview/application/service/asset_service.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from decimal import Decimal + +from coding_interview.domain.stock import Account, Stock +from coding_interview.domain.stock_symbol import StockSymbol + + +def evaluate_stock(stock: Stock, prices: dict[StockSymbol, Decimal]) -> Decimal: + price = prices.get(stock.symbol) + if price is None: + raise IllegalStateError(f"missing price for {stock.symbol}") + return stock.qty * price + + +def total_valuation(account: Account, prices: dict[StockSymbol, Decimal]) -> Decimal: + return sum(evaluate_stock(s, prices) for s in account.stocks) + account.cash + + +class IllegalStateError(Exception): + pass diff --git a/python/src/coding_interview/application/service/portfolio_service.py b/python/src/coding_interview/application/service/portfolio_service.py new file mode 100644 index 0000000..dc8b13d --- /dev/null +++ b/python/src/coding_interview/application/service/portfolio_service.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_DOWN + +from coding_interview.domain.constants import CASH_RATE +from coding_interview.domain.stock import Account, Stock, Portfolio +from coding_interview.domain.stock_symbol import StockSymbol +from coding_interview.application.service.asset_service import total_valuation + +_TWO_DP = Decimal("0.01") +_ZERO_DP = Decimal("1") + + +def _floor2(x: Decimal) -> Decimal: + return x.quantize(_TWO_DP, rounding=ROUND_DOWN) + + +def _floor0(x: Decimal) -> Decimal: + return x.quantize(_ZERO_DP, rounding=ROUND_DOWN) + + +def _price_of(prices: dict[StockSymbol, Decimal], symbol: StockSymbol) -> Decimal: + price = prices.get(symbol) + if price is None: + raise ValueError(f"missing price for {symbol}") + return price + + +def allocate_new( + amount: Decimal, + portfolio: Portfolio, + prices: dict[StockSymbol, Decimal], +) -> Account: + cash_from_rate = _floor0(amount * CASH_RATE) + investable = amount - cash_from_rate + stocks = tuple( + Stock(item.symbol, _floor2(investable * item.rate / _price_of(prices, item.symbol))) + for item in portfolio.items + ) + used_for_stocks = sum(s.qty * _price_of(prices, s.symbol) for s in stocks) + residual = investable - used_for_stocks + return Account(cash=cash_from_rate + residual, stocks=stocks) + + +def allocate_additional( + account: Account, + amount: Decimal, + portfolio: Portfolio, + prices: dict[StockSymbol, Decimal], +) -> Account: + total_after = total_valuation(account, prices) + amount + target_cash = _floor0(total_after * CASH_RATE) + investable = total_after - target_cash + current_qty: dict[StockSymbol, Decimal] = {s.symbol: s.qty for s in account.stocks} + portfolio_symbols = {item.symbol for item in portfolio.items} + + new_portfolio_stocks = [] + for item in portfolio.items: + target_qty = _floor2(investable * item.rate / _price_of(prices, item.symbol)) + current = current_qty.get(item.symbol, Decimal(0)) + final_qty = target_qty if target_qty > current else current + new_portfolio_stocks.append(Stock(item.symbol, final_qty)) + + preserved_stocks = [s for s in account.stocks if s.symbol not in portfolio_symbols] + all_stocks = tuple(new_portfolio_stocks + preserved_stocks) + + final_valuation = sum(s.qty * _price_of(prices, s.symbol) for s in all_stocks) + return Account(cash=total_after - final_valuation, stocks=all_stocks) + + +def rebalance( + account: Account, + portfolio: Portfolio, + prices: dict[StockSymbol, Decimal], +) -> Account: + # XXX this implementation might not be correct + investable = total_valuation(account, prices) + new_stocks = tuple( + Stock(item.symbol, _floor2(investable * item.rate / _price_of(prices, item.symbol))) + for item in portfolio.items + ) + final_valuation = sum(s.qty * _price_of(prices, s.symbol) for s in new_stocks) + return Account(cash=investable - final_valuation, stocks=new_stocks) diff --git a/python/src/coding_interview/application/usecase/__init__.py b/python/src/coding_interview/application/usecase/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/application/usecase/asset/__init__.py b/python/src/coding_interview/application/usecase/asset/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/application/usecase/asset/get_asset_usecase.py b/python/src/coding_interview/application/usecase/asset/get_asset_usecase.py new file mode 100644 index 0000000..ad10f05 --- /dev/null +++ b/python/src/coding_interview/application/usecase/asset/get_asset_usecase.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from coding_interview.application.repository.account_repository import AccountRepository +from coding_interview.application.repository.market_price_repository import MarketPriceRepository +from coding_interview.application.service.asset_service import evaluate_stock +from coding_interview.application.usecase.exceptions import UserNotFoundError +from coding_interview.domain.stock_symbol import StockSymbol +from coding_interview.domain.user_id import UserId + + +@dataclass(frozen=True) +class GetAssetUsecaseInput: + user_id: UserId + + +@dataclass(frozen=True) +class GetAssetStockOutput: + symbol: StockSymbol + evaluation_amount: Decimal + + +@dataclass(frozen=True) +class GetAssetUsecaseOutput: + cash_amount: Decimal + stocks: tuple[GetAssetStockOutput, ...] + + +class GetAssetUsecase: + def __init__( + self, + account_repository: AccountRepository, + market_price_repository: MarketPriceRepository, + ) -> None: + self._account_repository = account_repository + self._market_price_repository = market_price_repository + + def run(self, input: GetAssetUsecaseInput) -> GetAssetUsecaseOutput: + account = self._account_repository.find(input.user_id) + if account is None: + raise UserNotFoundError() + prices = self._market_price_repository.all() + stocks = tuple( + GetAssetStockOutput(symbol=s.symbol, evaluation_amount=evaluate_stock(s, prices)) + for s in account.stocks + ) + return GetAssetUsecaseOutput(cash_amount=account.cash, stocks=stocks) diff --git a/python/src/coding_interview/application/usecase/exceptions.py b/python/src/coding_interview/application/usecase/exceptions.py new file mode 100644 index 0000000..beaefc6 --- /dev/null +++ b/python/src/coding_interview/application/usecase/exceptions.py @@ -0,0 +1,23 @@ +from __future__ import annotations + + +class UsecaseException(Exception): + pass + + +class UserNotFoundError(UsecaseException): + pass + + +class UserAlreadyExistsError(UsecaseException): + pass + + +class AmountTooSmallError(UsecaseException): + pass + + +class InvalidPortfolioError(UsecaseException): + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason diff --git a/python/src/coding_interview/application/usecase/market_price/__init__.py b/python/src/coding_interview/application/usecase/market_price/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/application/usecase/market_price/update_market_price_usecase.py b/python/src/coding_interview/application/usecase/market_price/update_market_price_usecase.py new file mode 100644 index 0000000..d676215 --- /dev/null +++ b/python/src/coding_interview/application/usecase/market_price/update_market_price_usecase.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from coding_interview.application.repository.market_price_repository import MarketPriceRepository +from coding_interview.domain.stock_symbol import StockSymbol + + +@dataclass(frozen=True) +class UpdateMarketPriceItemInput: + symbol: StockSymbol + market_price: Decimal + + +@dataclass(frozen=True) +class UpdateMarketPriceUsecaseInput: + items: tuple[UpdateMarketPriceItemInput, ...] + + +class UpdateMarketPriceUsecase: + def __init__(self, market_price_repository: MarketPriceRepository) -> None: + self._market_price_repository = market_price_repository + + def run(self, input: UpdateMarketPriceUsecaseInput) -> None: + prices = {item.symbol: item.market_price for item in input.items} + self._market_price_repository.update(prices) diff --git a/python/src/coding_interview/application/usecase/order/__init__.py b/python/src/coding_interview/application/usecase/order/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/application/usecase/order/additional_buy_order_usecase.py b/python/src/coding_interview/application/usecase/order/additional_buy_order_usecase.py new file mode 100644 index 0000000..b4acbdd --- /dev/null +++ b/python/src/coding_interview/application/usecase/order/additional_buy_order_usecase.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from coding_interview.application.repository.account_repository import AccountRepository +from coding_interview.application.repository.market_price_repository import MarketPriceRepository +from coding_interview.application.repository.portfolio_repository import PortfolioRepository +from coding_interview.application.service.portfolio_service import allocate_additional +from coding_interview.application.usecase.exceptions import AmountTooSmallError, UserNotFoundError +from coding_interview.domain.constants import MIN_OPERATION_AMOUNT +from coding_interview.domain.user_id import UserId + + +@dataclass(frozen=True) +class AdditionalBuyOrderUsecaseInput: + user_id: UserId + amount: Decimal + + +class AdditionalBuyOrderUsecase: + def __init__( + self, + account_repository: AccountRepository, + portfolio_repository: PortfolioRepository, + market_price_repository: MarketPriceRepository, + ) -> None: + self._account_repository = account_repository + self._portfolio_repository = portfolio_repository + self._market_price_repository = market_price_repository + + def run(self, input: AdditionalBuyOrderUsecaseInput) -> None: + if input.amount < MIN_OPERATION_AMOUNT: + raise AmountTooSmallError() + account = self._account_repository.find(input.user_id) + if account is None: + raise UserNotFoundError() + portfolio = self._portfolio_repository.get() + prices = self._market_price_repository.all() + new_account = allocate_additional(account, input.amount, portfolio, prices) + self._account_repository.upsert(input.user_id, new_account) diff --git a/python/src/coding_interview/application/usecase/order/new_contribution_order_usecase.py b/python/src/coding_interview/application/usecase/order/new_contribution_order_usecase.py new file mode 100644 index 0000000..ced8afd --- /dev/null +++ b/python/src/coding_interview/application/usecase/order/new_contribution_order_usecase.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from coding_interview.application.repository.account_repository import AccountRepository +from coding_interview.application.repository.market_price_repository import MarketPriceRepository +from coding_interview.application.repository.portfolio_repository import PortfolioRepository +from coding_interview.application.service.portfolio_service import allocate_new +from coding_interview.application.usecase.exceptions import AmountTooSmallError, UserAlreadyExistsError +from coding_interview.domain.constants import MIN_OPERATION_AMOUNT +from coding_interview.domain.user_id import UserId + + +@dataclass(frozen=True) +class NewContributionOrderUsecaseInput: + user_id: UserId + amount: Decimal + + +class NewContributionOrderUsecase: + def __init__( + self, + account_repository: AccountRepository, + portfolio_repository: PortfolioRepository, + market_price_repository: MarketPriceRepository, + ) -> None: + self._account_repository = account_repository + self._portfolio_repository = portfolio_repository + self._market_price_repository = market_price_repository + + def run(self, input: NewContributionOrderUsecaseInput) -> None: + if input.amount < MIN_OPERATION_AMOUNT: + raise AmountTooSmallError() + if self._account_repository.exists(input.user_id): + raise UserAlreadyExistsError() + portfolio = self._portfolio_repository.get() + prices = self._market_price_repository.all() + account = allocate_new(input.amount, portfolio, prices) + self._account_repository.upsert(input.user_id, account) diff --git a/python/src/coding_interview/application/usecase/order/rebalance_order_usecase.py b/python/src/coding_interview/application/usecase/order/rebalance_order_usecase.py new file mode 100644 index 0000000..fd2b922 --- /dev/null +++ b/python/src/coding_interview/application/usecase/order/rebalance_order_usecase.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from coding_interview.application.repository.account_repository import AccountRepository +from coding_interview.application.repository.market_price_repository import MarketPriceRepository +from coding_interview.application.repository.portfolio_repository import PortfolioRepository +from coding_interview.application.service.portfolio_service import rebalance +from coding_interview.application.usecase.exceptions import UserNotFoundError +from coding_interview.domain.user_id import UserId + + +@dataclass(frozen=True) +class RebalanceOrderUsecaseInput: + user_id: UserId + + +class RebalanceOrderUsecase: + def __init__( + self, + account_repository: AccountRepository, + portfolio_repository: PortfolioRepository, + market_price_repository: MarketPriceRepository, + ) -> None: + self._account_repository = account_repository + self._portfolio_repository = portfolio_repository + self._market_price_repository = market_price_repository + + def run(self, input: RebalanceOrderUsecaseInput) -> None: + account = self._account_repository.find(input.user_id) + if account is None: + raise UserNotFoundError() + portfolio = self._portfolio_repository.get() + prices = self._market_price_repository.all() + new_account = rebalance(account, portfolio, prices) + self._account_repository.upsert(input.user_id, new_account) diff --git a/python/src/coding_interview/application/usecase/portfolio/__init__.py b/python/src/coding_interview/application/usecase/portfolio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/application/usecase/portfolio/get_latest_portfolio_usecase.py b/python/src/coding_interview/application/usecase/portfolio/get_latest_portfolio_usecase.py new file mode 100644 index 0000000..e2dc530 --- /dev/null +++ b/python/src/coding_interview/application/usecase/portfolio/get_latest_portfolio_usecase.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from coding_interview.application.repository.portfolio_repository import PortfolioRepository +from coding_interview.domain.stock_symbol import StockSymbol + + +@dataclass(frozen=True) +class GetLatestPortfolioItemOutput: + symbol: StockSymbol + rate: Decimal + + +@dataclass(frozen=True) +class GetLatestPortfolioUsecaseOutput: + items: tuple[GetLatestPortfolioItemOutput, ...] + + +class GetLatestPortfolioUsecase: + def __init__(self, portfolio_repository: PortfolioRepository) -> None: + self._portfolio_repository = portfolio_repository + + def run(self) -> GetLatestPortfolioUsecaseOutput: + portfolio = self._portfolio_repository.get() + items = tuple( + GetLatestPortfolioItemOutput(symbol=item.symbol, rate=item.rate) + for item in portfolio.items + ) + return GetLatestPortfolioUsecaseOutput(items=items) diff --git a/python/src/coding_interview/application/usecase/portfolio/update_portfolio_usecase.py b/python/src/coding_interview/application/usecase/portfolio/update_portfolio_usecase.py new file mode 100644 index 0000000..291785e --- /dev/null +++ b/python/src/coding_interview/application/usecase/portfolio/update_portfolio_usecase.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from coding_interview.application.repository.portfolio_repository import PortfolioRepository +from coding_interview.application.usecase.exceptions import InvalidPortfolioError +from coding_interview.domain.stock import Portfolio, PortfolioItem +from coding_interview.domain.stock_symbol import StockSymbol + + +@dataclass(frozen=True) +class UpdatePortfolioItemInput: + symbol: StockSymbol + rate: Decimal + + +@dataclass(frozen=True) +class UpdatePortfolioUsecaseInput: + items: tuple[UpdatePortfolioItemInput, ...] + + +class UpdatePortfolioUsecase: + def __init__(self, portfolio_repository: PortfolioRepository) -> None: + self._portfolio_repository = portfolio_repository + + def run(self, input: UpdatePortfolioUsecaseInput) -> None: + try: + portfolio = Portfolio( + items=tuple(PortfolioItem(i.symbol, i.rate) for i in input.items) + ) + except (ValueError, Exception) as e: + raise InvalidPortfolioError(str(e)) from e + self._portfolio_repository.update(portfolio) diff --git a/python/src/coding_interview/domain/__init__.py b/python/src/coding_interview/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/domain/constants.py b/python/src/coding_interview/domain/constants.py new file mode 100644 index 0000000..77bf826 --- /dev/null +++ b/python/src/coding_interview/domain/constants.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from decimal import Decimal + +from coding_interview.domain.stock import Portfolio, PortfolioItem +from coding_interview.domain.stock_symbol import StockSymbol + +CASH_RATE: Decimal = Decimal("0.05") +MIN_OPERATION_AMOUNT: Decimal = Decimal("10000") + +SUPPORTED_SYMBOLS: tuple[StockSymbol, ...] = (StockSymbol.Toyopa, StockSymbol.Somy) + +INITIAL_PRICES: dict[StockSymbol, Decimal] = { + StockSymbol.Toyopa: Decimal("4.2135"), + StockSymbol.Somy: Decimal("1.2345"), +} + +INITIAL_PORTFOLIO: Portfolio = Portfolio( + items=( + PortfolioItem(StockSymbol.Toyopa, Decimal("0.40")), + PortfolioItem(StockSymbol.Somy, Decimal("0.60")), + ) +) diff --git a/python/src/coding_interview/domain/stock.py b/python/src/coding_interview/domain/stock.py new file mode 100644 index 0000000..a29e9be --- /dev/null +++ b/python/src/coding_interview/domain/stock.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from coding_interview.domain.stock_symbol import StockSymbol + + +@dataclass(frozen=True) +class Stock: + symbol: StockSymbol + qty: Decimal + + +@dataclass(frozen=True) +class PortfolioItem: + symbol: StockSymbol + rate: Decimal + + +@dataclass(frozen=True) +class Portfolio: + items: tuple[PortfolioItem, ...] + + def __post_init__(self) -> None: + if not self.items: + raise ValueError("portfolio must have at least one item") + rate_sum = sum(item.rate for item in self.items) + if rate_sum != Decimal(1): + raise ValueError(f"portfolio rates must sum to 1, got {rate_sum}") + symbols = [item.symbol for item in self.items] + if len(symbols) != len(set(symbols)): + raise ValueError("portfolio must not have duplicate symbols") + + +@dataclass(frozen=True) +class Account: + cash: Decimal + stocks: tuple[Stock, ...] diff --git a/python/src/coding_interview/domain/stock_symbol.py b/python/src/coding_interview/domain/stock_symbol.py new file mode 100644 index 0000000..093bba0 --- /dev/null +++ b/python/src/coding_interview/domain/stock_symbol.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from enum import Enum + + +class StockSymbol(Enum): + Toyopa = "Toyopa" + Somy = "Somy" + + @classmethod + def from_string(cls, s: str) -> "StockSymbol | None": + try: + return cls(s) + except ValueError: + return None + + def __str__(self) -> str: + return self.value diff --git a/python/src/coding_interview/domain/user_id.py b/python/src/coding_interview/domain/user_id.py new file mode 100644 index 0000000..40fdd0a --- /dev/null +++ b/python/src/coding_interview/domain/user_id.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UserId: + value: str + + def __post_init__(self) -> None: + if not self.value: + raise ValueError("userId must not be empty") diff --git a/python/src/coding_interview/infrastructure/__init__.py b/python/src/coding_interview/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/infrastructure/repository/__init__.py b/python/src/coding_interview/infrastructure/repository/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/infrastructure/repository/account_repository_impl.py b/python/src/coding_interview/infrastructure/repository/account_repository_impl.py new file mode 100644 index 0000000..9f34a29 --- /dev/null +++ b/python/src/coding_interview/infrastructure/repository/account_repository_impl.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from coding_interview.domain.stock import Account +from coding_interview.domain.user_id import UserId + + +class AccountRepositoryImpl: + def __init__(self) -> None: + self._store: dict[str, Account] = {} + + def find(self, user_id: UserId) -> Account | None: + return self._store.get(user_id.value) + + def upsert(self, user_id: UserId, account: Account) -> None: + self._store[user_id.value] = account + + def exists(self, user_id: UserId) -> bool: + return user_id.value in self._store diff --git a/python/src/coding_interview/infrastructure/repository/market_price_repository_impl.py b/python/src/coding_interview/infrastructure/repository/market_price_repository_impl.py new file mode 100644 index 0000000..8f67fb8 --- /dev/null +++ b/python/src/coding_interview/infrastructure/repository/market_price_repository_impl.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from decimal import Decimal + +from coding_interview.domain.constants import INITIAL_PRICES +from coding_interview.domain.stock_symbol import StockSymbol + + +class MarketPriceRepositoryImpl: + def __init__(self) -> None: + self._prices: dict[StockSymbol, Decimal] = dict(INITIAL_PRICES) + + def all(self) -> dict[StockSymbol, Decimal]: + return dict(self._prices) + + def update(self, prices: dict[StockSymbol, Decimal]) -> None: + self._prices = dict(prices) diff --git a/python/src/coding_interview/infrastructure/repository/portfolio_repository_impl.py b/python/src/coding_interview/infrastructure/repository/portfolio_repository_impl.py new file mode 100644 index 0000000..daf55f3 --- /dev/null +++ b/python/src/coding_interview/infrastructure/repository/portfolio_repository_impl.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from coding_interview.domain.constants import INITIAL_PORTFOLIO +from coding_interview.domain.stock import Portfolio + + +class PortfolioRepositoryImpl: + def __init__(self) -> None: + self._portfolio: Portfolio = INITIAL_PORTFOLIO + + def get(self) -> Portfolio: + return self._portfolio + + def update(self, portfolio: Portfolio) -> None: + self._portfolio = portfolio diff --git a/python/src/coding_interview/infrastructure/server/__init__.py b/python/src/coding_interview/infrastructure/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/infrastructure/server/dummy_server.py b/python/src/coding_interview/infrastructure/server/dummy_server.py new file mode 100644 index 0000000..a8c05b0 --- /dev/null +++ b/python/src/coding_interview/infrastructure/server/dummy_server.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from coding_interview.application.usecase.asset.get_asset_usecase import GetAssetUsecase +from coding_interview.application.usecase.market_price.update_market_price_usecase import UpdateMarketPriceUsecase +from coding_interview.application.usecase.order.additional_buy_order_usecase import AdditionalBuyOrderUsecase +from coding_interview.application.usecase.order.new_contribution_order_usecase import NewContributionOrderUsecase +from coding_interview.application.usecase.order.rebalance_order_usecase import RebalanceOrderUsecase +from coding_interview.application.usecase.portfolio.get_latest_portfolio_usecase import GetLatestPortfolioUsecase +from coding_interview.application.usecase.portfolio.update_portfolio_usecase import UpdatePortfolioUsecase +from coding_interview.infrastructure.repository.account_repository_impl import AccountRepositoryImpl +from coding_interview.infrastructure.repository.market_price_repository_impl import MarketPriceRepositoryImpl +from coding_interview.infrastructure.repository.portfolio_repository_impl import PortfolioRepositoryImpl +from coding_interview.presentation.asset_controller import AssetController +from coding_interview.presentation.market_price_controller import MarketPriceController +from coding_interview.presentation.order_controller import OrderController +from coding_interview.presentation.portfolio_controller import PortfolioController + + +@dataclass +class DummyServer: + asset_controller: AssetController + portfolio_controller: PortfolioController + order_controller: OrderController + market_price_controller: MarketPriceController + + @classmethod + def default(cls) -> "DummyServer": + portfolio_repository = PortfolioRepositoryImpl() + account_repository = AccountRepositoryImpl() + market_price_repository = MarketPriceRepositoryImpl() + + get_asset_usecase = GetAssetUsecase(account_repository, market_price_repository) + get_latest_portfolio_usecase = GetLatestPortfolioUsecase(portfolio_repository) + update_portfolio_usecase = UpdatePortfolioUsecase(portfolio_repository) + update_market_price_usecase = UpdateMarketPriceUsecase(market_price_repository) + new_contribution_order_usecase = NewContributionOrderUsecase( + account_repository, portfolio_repository, market_price_repository + ) + additional_buy_order_usecase = AdditionalBuyOrderUsecase( + account_repository, portfolio_repository, market_price_repository + ) + rebalance_order_usecase = RebalanceOrderUsecase( + account_repository, portfolio_repository, market_price_repository + ) + + return cls( + asset_controller=AssetController(get_asset_usecase), + portfolio_controller=PortfolioController( + get_latest_portfolio_usecase, update_portfolio_usecase + ), + order_controller=OrderController( + new_contribution_order_usecase, + additional_buy_order_usecase, + rebalance_order_usecase, + ), + market_price_controller=MarketPriceController(update_market_price_usecase), + ) diff --git a/python/src/coding_interview/infrastructure/server/main.py b/python/src/coding_interview/infrastructure/server/main.py new file mode 100644 index 0000000..dfdf6ab --- /dev/null +++ b/python/src/coding_interview/infrastructure/server/main.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from coding_interview.infrastructure.server.dummy_server import DummyServer + + +def main() -> None: + DummyServer.default() + print("DummyServer started") + + +if __name__ == "__main__": + main() diff --git a/python/src/coding_interview/presentation/__init__.py b/python/src/coding_interview/presentation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/coding_interview/presentation/asset_controller.py b/python/src/coding_interview/presentation/asset_controller.py new file mode 100644 index 0000000..4a30bda --- /dev/null +++ b/python/src/coding_interview/presentation/asset_controller.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from coding_interview.application.usecase.asset.get_asset_usecase import ( + GetAssetUsecase, + GetAssetUsecaseInput, +) +from coding_interview.application.usecase.exceptions import UserNotFoundError +from coding_interview.presentation.exceptions import BadRequestException +from coding_interview.presentation.preparation import parse_user_id + + +@dataclass(frozen=True) +class StockDto: + symbol: str + evaluationAmount: str + + +@dataclass(frozen=True) +class GetAssetRequest: + userId: str + + +@dataclass(frozen=True) +class GetAssetResponse: + cashAmount: str + stocks: tuple[StockDto, ...] + + +class AssetController: + def __init__(self, get_asset_usecase: GetAssetUsecase) -> None: + self._get_asset_usecase = get_asset_usecase + + def get_asset(self, req: GetAssetRequest) -> GetAssetResponse: + uid = parse_user_id(req.userId) + try: + out = self._get_asset_usecase.run(GetAssetUsecaseInput(uid)) + except UserNotFoundError: + raise BadRequestException("user not found") + return GetAssetResponse( + cashAmount=str(out.cash_amount), + stocks=tuple(StockDto(symbol=str(s.symbol), evaluationAmount=str(s.evaluation_amount)) for s in out.stocks), + ) diff --git a/python/src/coding_interview/presentation/exceptions.py b/python/src/coding_interview/presentation/exceptions.py new file mode 100644 index 0000000..3daa7cd --- /dev/null +++ b/python/src/coding_interview/presentation/exceptions.py @@ -0,0 +1,11 @@ +from __future__ import annotations + + +class PresentationException(Exception): + pass + + +class BadRequestException(PresentationException): + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message diff --git a/python/src/coding_interview/presentation/market_price_controller.py b/python/src/coding_interview/presentation/market_price_controller.py new file mode 100644 index 0000000..7a21b1e --- /dev/null +++ b/python/src/coding_interview/presentation/market_price_controller.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation + +from coding_interview.application.usecase.market_price.update_market_price_usecase import ( + UpdateMarketPriceItemInput, + UpdateMarketPriceUsecase, + UpdateMarketPriceUsecaseInput, +) +from coding_interview.domain.stock_symbol import StockSymbol +from coding_interview.presentation.exceptions import BadRequestException + + +@dataclass(frozen=True) +class MarketPriceItemDto: + symbol: str + market_price: str + + +@dataclass(frozen=True) +class UpdateMarketPriceRequest: + market_prices: tuple[MarketPriceItemDto, ...] + + +class MarketPriceController: + def __init__(self, update_market_price_usecase: UpdateMarketPriceUsecase) -> None: + self._update_market_price_usecase = update_market_price_usecase + + def update_market_price(self, req: UpdateMarketPriceRequest) -> None: + items: list[UpdateMarketPriceItemInput] = [] + for dto in req.market_prices: + sym = StockSymbol.from_string(dto.symbol) + if sym is None: + raise BadRequestException(f"unknown symbol: {dto.symbol}") + try: + price = Decimal(dto.market_price) + except (InvalidOperation, Exception): + raise BadRequestException(f"invalid market_price: {dto.market_price}") + items.append(UpdateMarketPriceItemInput(sym, price)) + self._update_market_price_usecase.run(UpdateMarketPriceUsecaseInput(tuple(items))) diff --git a/python/src/coding_interview/presentation/order_controller.py b/python/src/coding_interview/presentation/order_controller.py new file mode 100644 index 0000000..19600d1 --- /dev/null +++ b/python/src/coding_interview/presentation/order_controller.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from coding_interview.application.usecase.exceptions import ( + AmountTooSmallError, + UserAlreadyExistsError, + UserNotFoundError, +) +from coding_interview.application.usecase.order.additional_buy_order_usecase import ( + AdditionalBuyOrderUsecase, + AdditionalBuyOrderUsecaseInput, +) +from coding_interview.application.usecase.order.new_contribution_order_usecase import ( + NewContributionOrderUsecase, + NewContributionOrderUsecaseInput, +) +from coding_interview.application.usecase.order.rebalance_order_usecase import ( + RebalanceOrderUsecase, + RebalanceOrderUsecaseInput, +) +from coding_interview.presentation.exceptions import BadRequestException +from coding_interview.presentation.preparation import parse_amount, parse_user_id + + +@dataclass(frozen=True) +class NewContributionOrderRequest: + userId: str + amount: str + + +@dataclass(frozen=True) +class AdditionalContributionOrderRequest: + userId: str + amount: str + + +@dataclass(frozen=True) +class RebalanceOrderRequest: + userId: str + + +class OrderController: + def __init__( + self, + new_contribution_order_usecase: NewContributionOrderUsecase, + additional_buy_order_usecase: AdditionalBuyOrderUsecase, + rebalance_order_usecase: RebalanceOrderUsecase, + ) -> None: + self._new_contribution_order_usecase = new_contribution_order_usecase + self._additional_buy_order_usecase = additional_buy_order_usecase + self._rebalance_order_usecase = rebalance_order_usecase + + def new_contribution_order(self, req: NewContributionOrderRequest) -> None: + uid = parse_user_id(req.userId) + amt = parse_amount(req.amount) + try: + self._new_contribution_order_usecase.run(NewContributionOrderUsecaseInput(uid, amt)) + except UserAlreadyExistsError: + raise BadRequestException("user already has account") + except AmountTooSmallError: + raise BadRequestException("amount is too small") + + def additional_contribution_order(self, req: AdditionalContributionOrderRequest) -> None: + uid = parse_user_id(req.userId) + amt = parse_amount(req.amount) + try: + self._additional_buy_order_usecase.run(AdditionalBuyOrderUsecaseInput(uid, amt)) + except UserNotFoundError: + raise BadRequestException("user has no live account") + except AmountTooSmallError: + raise BadRequestException("amount is too small") + + def rebalance_order(self, req: RebalanceOrderRequest) -> None: + uid = parse_user_id(req.userId) + try: + self._rebalance_order_usecase.run(RebalanceOrderUsecaseInput(uid)) + except UserNotFoundError: + raise BadRequestException("user has no live account") diff --git a/python/src/coding_interview/presentation/portfolio_controller.py b/python/src/coding_interview/presentation/portfolio_controller.py new file mode 100644 index 0000000..8b67f41 --- /dev/null +++ b/python/src/coding_interview/presentation/portfolio_controller.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation + +from coding_interview.application.usecase.exceptions import InvalidPortfolioError +from coding_interview.application.usecase.portfolio.get_latest_portfolio_usecase import GetLatestPortfolioUsecase +from coding_interview.application.usecase.portfolio.update_portfolio_usecase import ( + UpdatePortfolioItemInput, + UpdatePortfolioUsecase, + UpdatePortfolioUsecaseInput, +) +from coding_interview.domain.stock_symbol import StockSymbol +from coding_interview.presentation.exceptions import BadRequestException + + +@dataclass(frozen=True) +class PortfolioItemDto: + symbol: str + rate: str + + +@dataclass(frozen=True) +class GetOptimalPortfolioResponse: + portfolios: tuple[PortfolioItemDto, ...] + + +@dataclass(frozen=True) +class UpdateOptimalPortfolioRequest: + portfolios: tuple[PortfolioItemDto, ...] + + +class PortfolioController: + def __init__( + self, + get_latest_portfolio_usecase: GetLatestPortfolioUsecase, + update_portfolio_usecase: UpdatePortfolioUsecase, + ) -> None: + self._get_latest_portfolio_usecase = get_latest_portfolio_usecase + self._update_portfolio_usecase = update_portfolio_usecase + + def get_optimal_portfolio(self) -> GetOptimalPortfolioResponse: + out = self._get_latest_portfolio_usecase.run() + return GetOptimalPortfolioResponse( + portfolios=tuple(PortfolioItemDto(symbol=str(i.symbol), rate=str(i.rate)) for i in out.items) + ) + + def update_optimal_portfolio(self, req: UpdateOptimalPortfolioRequest) -> None: + items: list[UpdatePortfolioItemInput] = [] + for dto in req.portfolios: + sym = StockSymbol.from_string(dto.symbol) + if sym is None: + raise BadRequestException(f"unknown symbol: {dto.symbol}") + try: + rate = Decimal(dto.rate) + except (InvalidOperation, Exception): + raise BadRequestException(f"invalid rate: {dto.rate}") + items.append(UpdatePortfolioItemInput(sym, rate)) + try: + self._update_portfolio_usecase.run(UpdatePortfolioUsecaseInput(tuple(items))) + except InvalidPortfolioError as e: + raise BadRequestException(e.reason) diff --git a/python/src/coding_interview/presentation/preparation.py b/python/src/coding_interview/presentation/preparation.py new file mode 100644 index 0000000..ed362d3 --- /dev/null +++ b/python/src/coding_interview/presentation/preparation.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation + +from coding_interview.domain.user_id import UserId +from coding_interview.presentation.exceptions import BadRequestException + + +def parse_user_id(s: str) -> UserId: + try: + return UserId(s) + except (ValueError, Exception) as e: + raise BadRequestException(str(e)) from e + + +def parse_amount(s: str) -> Decimal: + try: + return Decimal(s) + except (InvalidOperation, Exception) as e: + raise BadRequestException(f"invalid amount: {s}") from e diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/tests/test_optimal_portfolio_scenario.py b/python/tests/test_optimal_portfolio_scenario.py new file mode 100644 index 0000000..4813491 --- /dev/null +++ b/python/tests/test_optimal_portfolio_scenario.py @@ -0,0 +1,37 @@ +from decimal import Decimal + +import pytest + +from coding_interview.infrastructure.server.dummy_server import DummyServer +from coding_interview.presentation.portfolio_controller import PortfolioItemDto, UpdateOptimalPortfolioRequest + + +@pytest.fixture() +def server() -> DummyServer: + return DummyServer.default() + + +def test_optimal_portfolio_scenario(server: DummyServer) -> None: + pc = server.portfolio_controller + + # Toyopa=0.20 / Somy=0.80 に更新して確認 + pc.update_optimal_portfolio( + UpdateOptimalPortfolioRequest( + portfolios=(PortfolioItemDto("Toyopa", "0.20"), PortfolioItemDto("Somy", "0.80")) + ) + ) + resp1 = pc.get_optimal_portfolio() + rates1 = {item.symbol: Decimal(item.rate) for item in resp1.portfolios} + assert rates1["Toyopa"] == Decimal("0.20") + assert rates1["Somy"] == Decimal("0.80") + + # Toyopa=0.40 / Somy=0.60 に更新して確認 + pc.update_optimal_portfolio( + UpdateOptimalPortfolioRequest( + portfolios=(PortfolioItemDto("Toyopa", "0.40"), PortfolioItemDto("Somy", "0.60")) + ) + ) + resp2 = pc.get_optimal_portfolio() + rates2 = {item.symbol: Decimal(item.rate) for item in resp2.portfolios} + assert rates2["Toyopa"] == Decimal("0.40") + assert rates2["Somy"] == Decimal("0.60") diff --git a/python/tests/test_order_scenario.py b/python/tests/test_order_scenario.py new file mode 100644 index 0000000..7d703ba --- /dev/null +++ b/python/tests/test_order_scenario.py @@ -0,0 +1,102 @@ +import uuid +from decimal import Decimal + +import pytest + +from coding_interview.infrastructure.server.dummy_server import DummyServer +from coding_interview.presentation.asset_controller import GetAssetRequest +from coding_interview.presentation.exceptions import BadRequestException +from coding_interview.presentation.market_price_controller import MarketPriceItemDto, UpdateMarketPriceRequest +from coding_interview.presentation.order_controller import ( + AdditionalContributionOrderRequest, + NewContributionOrderRequest, + RebalanceOrderRequest, +) +from coding_interview.presentation.portfolio_controller import PortfolioItemDto, UpdateOptimalPortfolioRequest + + +@pytest.fixture() +def server() -> DummyServer: + s = DummyServer.default() + # 各テスト前に価格とポートフォリオを初期化 + s.portfolio_controller.update_optimal_portfolio( + UpdateOptimalPortfolioRequest( + portfolios=(PortfolioItemDto("Toyopa", "0.40"), PortfolioItemDto("Somy", "0.60")) + ) + ) + s.market_price_controller.update_market_price( + UpdateMarketPriceRequest( + market_prices=(MarketPriceItemDto("Toyopa", "2.5"), MarketPriceItemDto("Somy", "3.0")) + ) + ) + return s + + +def test_investment_operation_scenario(server: DummyServer) -> None: + ac = server.asset_controller + pc = server.portfolio_controller + oc = server.order_controller + + user_id = str(uuid.uuid4()) + + # 存在しないユーザーで資産を取得しようとする + with pytest.raises(BadRequestException): + ac.get_asset(GetAssetRequest(user_id)) + # BadRequestException が返される + + # 最適ポートフォリオを Toyopa=40%, Somy=60% に更新する + pc.update_optimal_portfolio( + UpdateOptimalPortfolioRequest( + portfolios=(PortfolioItemDto("Toyopa", "0.40"), PortfolioItemDto("Somy", "0.60")) + ) + ) + + # 新規拠出を 100,000 円で注文する + oc.new_contribution_order(NewContributionOrderRequest(user_id, "100000")) + + asset1 = ac.get_asset(GetAssetRequest(user_id)) + assert {s.symbol for s in asset1.stocks} == {"Toyopa", "Somy"} + total1 = Decimal(asset1.cashAmount) + sum(Decimal(s.evaluationAmount) for s in asset1.stocks) + assert abs(total1 - Decimal("100000")) <= Decimal("2") + + # 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる + asset1_toyopa = next(s for s in asset1.stocks if s.symbol == "Toyopa") + asset1_somy = next(s for s in asset1.stocks if s.symbol == "Somy") + assert Decimal(asset1_toyopa.evaluationAmount) == Decimal("38000") + assert Decimal(asset1_somy.evaluationAmount) == Decimal("57000") + assert Decimal(asset1.cashAmount) == Decimal("5000") + + # 追加拠出を 100,000 円で注文する + oc.additional_contribution_order(AdditionalContributionOrderRequest(user_id, "100000")) + + # 資産合計が約 200,000 円になる + asset2 = ac.get_asset(GetAssetRequest(user_id)) + total2 = Decimal(asset2.cashAmount) + sum(Decimal(s.evaluationAmount) for s in asset2.stocks) + assert abs(total2 - Decimal("200000")) <= Decimal("4") + + # 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる + asset2_toyopa = next(s for s in asset2.stocks if s.symbol == "Toyopa") + asset2_somy = next(s for s in asset2.stocks if s.symbol == "Somy") + assert Decimal(asset2_toyopa.evaluationAmount) == Decimal("76000") + assert Decimal(asset2_somy.evaluationAmount) == Decimal("114000") + assert Decimal(asset2.cashAmount) == Decimal("10000") + + # 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする + pc.update_optimal_portfolio( + UpdateOptimalPortfolioRequest( + portfolios=(PortfolioItemDto("Toyopa", "0.10"), PortfolioItemDto("Somy", "0.90")) + ) + ) + oc.rebalance_order(RebalanceOrderRequest(user_id)) + + # リバランス後も資産合計がほぼ変わらない + asset3 = ac.get_asset(GetAssetRequest(user_id)) + total3 = Decimal(asset3.cashAmount) + sum(Decimal(s.evaluationAmount) for s in asset3.stocks) + assert abs(total3 - total2) <= Decimal("4") + + # 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる + asset3_toyopa = next(s for s in asset3.stocks if s.symbol == "Toyopa") + asset3_somy = next(s for s in asset3.stocks if s.symbol == "Somy") + assert Decimal(asset3_toyopa.evaluationAmount) == Decimal("19000") + assert Decimal(asset3_somy.evaluationAmount) == Decimal("171000") + assert Decimal(asset3.cashAmount) == Decimal("10000") diff --git a/ruby/.gitignore b/ruby/.gitignore new file mode 100644 index 0000000..cf03d4f --- /dev/null +++ b/ruby/.gitignore @@ -0,0 +1,61 @@ +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/4d602a24bc5e4bbc2b8cedf08d4e982a80a7dfea/Ruby.gitignore + +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/spec/examples.txt +/test/tmp/ +/test/version_tmp/ +/tmp/ + +# Used by dotenv library to load environment variables. +# .env + +# Ignore Byebug command history file. +.byebug_history + +## Specific to RubyMotion: +.dat* +.repl_history +build/ +*.bridgesupport +build-iPhoneOS/ +build-iPhoneSimulator/ + +## Specific to RubyMotion (use of CocoaPods): +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# vendor/Pods/ + +## Documentation cache and generated files: +/.yardoc/ +/_yardoc/ +/doc/ +/rdoc/ + +## Environment normalization: +/.bundle/ +/vendor/bundle +/lib/bundler/man/ + +# for a library or gem, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# Gemfile.lock +# .ruby-version +# .ruby-gemset + +# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: +.rvmrc + +# Used by RuboCop. Remote config files pulled in from inherit_from directive. +# .rubocop-https?--* + + diff --git a/ruby/.rspec b/ruby/.rspec new file mode 100644 index 0000000..5be63fc --- /dev/null +++ b/ruby/.rspec @@ -0,0 +1,2 @@ +--require spec_helper +--format documentation diff --git a/ruby/Gemfile b/ruby/Gemfile new file mode 100644 index 0000000..d3f73fa --- /dev/null +++ b/ruby/Gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +group :test do + gem "rspec", "~> 3.13" +end diff --git a/ruby/Gemfile.lock b/ruby/Gemfile.lock new file mode 100644 index 0000000..116795e --- /dev/null +++ b/ruby/Gemfile.lock @@ -0,0 +1,26 @@ +GEM + remote: https://rubygems.org/ + specs: + diff-lcs (1.6.2) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + +PLATFORMS + ruby + +DEPENDENCIES + rspec (~> 3.13) + +BUNDLED WITH + 2.5.6 diff --git a/ruby/README.md b/ruby/README.md new file mode 100644 index 0000000..9f60d5c --- /dev/null +++ b/ruby/README.md @@ -0,0 +1,67 @@ +# サンプルラップサービス + +## 開発 + +- Ruby 3.0+ + +```shell +# 準備 +git init && git add . && git commit -m init + +# setup +bundle install --path vendor/bundle + +# test +bundle exec rspec +``` + +## サービス概要 + +このアプリケーションはロボアドバイザーサービスのバックエンドです。 + +### 株と評価額 + +- 株には株数(qty)があります(例: 1株、2株) +- 各株には1株あたりの市場価格があります(例: 1株あたり100円) +- 例: 顧客が5株保有している場合、評価額は `5株 × 100円 = 500円` となります + +### ロボアドバイザーサービス + +- **顧客の口座** + - 新規拠出を行うと、口座がすぐに開きます + - 口座の中で資産を管理することになります +- **顧客の資産** + - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します + - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円をいくつかの株で保有する + - 株は価格で保持するのではなく、株数で保持します + - そのため、市場価格に応じて評価額は変わることになります +- **最適ポートフォリオ** + - サービスが管理する、株の評価額ベースの構成比率 + - 例: A株を評価額の30%B株を評価額の70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30% B株95万円*70% になるように努める + - 購入時・売却時・リバランス時には、売買後の資産比率が現在の最適ポートフォリオに近づく形での売買を実施します +- **株の売買** + - 本アプリケーションでは、注文APIを叩くと即時必要な株の売買が成立し資産に反映出来るものとします +- 用語 + - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 + - 全売却注文: 運用中の株を全て売却すること。 + - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + +## 確認観点 + +- 成果を出すこと +- 成果物についての理解・責任を持つこと + +## 課題 + +以下の課題をAIを用いて、あなた自身の言葉・実装で回答してください。 + +1. API/アーキテクチャについてAIと協力しながら自分の言葉で説明してください +2. テストが全て通るようにしてください + - まずは現状のテストを走らせて、どのような内容で落ちているかを説明してください + - また、実装バグがあるため、テストコードは一切変更せず、AIと協力しながら実装を修正してください + - その上で、修正内容について説明をしてください +3. 全売却APIを実装してください + - 全売却後の現金の取り扱いに関しての方針を決めてください + - ストレッチ: 売却後の現金は銀行APIへ連携 +4. (部分売却APIを実装してください) diff --git a/ruby/lib/coding_interview/application/repository/account_repository.rb b/ruby/lib/coding_interview/application/repository/account_repository.rb new file mode 100644 index 0000000..5ec7262 --- /dev/null +++ b/ruby/lib/coding_interview/application/repository/account_repository.rb @@ -0,0 +1,12 @@ +module CodingInterview + module Application + module Repository + # 口座管理リポジトリ。 + class AccountRepository + def find(_user_id); raise NotImplementedError; end + def upsert(_user_id, _account); raise NotImplementedError; end + def exists?(_user_id); raise NotImplementedError; end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/repository/market_price_repository.rb b/ruby/lib/coding_interview/application/repository/market_price_repository.rb new file mode 100644 index 0000000..708121e --- /dev/null +++ b/ruby/lib/coding_interview/application/repository/market_price_repository.rb @@ -0,0 +1,11 @@ +module CodingInterview + module Application + module Repository + # 市場価格リポジトリ。 + class MarketPriceRepository + def all; raise NotImplementedError; end + def update(_prices); raise NotImplementedError; end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/repository/portfolio_repository.rb b/ruby/lib/coding_interview/application/repository/portfolio_repository.rb new file mode 100644 index 0000000..669b1a7 --- /dev/null +++ b/ruby/lib/coding_interview/application/repository/portfolio_repository.rb @@ -0,0 +1,11 @@ +module CodingInterview + module Application + module Repository + # 最適ポートフォリオリポジトリ。 + class PortfolioRepository + def get; raise NotImplementedError; end + def update(_portfolio); raise NotImplementedError; end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/service/asset_service.rb b/ruby/lib/coding_interview/application/service/asset_service.rb new file mode 100644 index 0000000..a0f0d87 --- /dev/null +++ b/ruby/lib/coding_interview/application/service/asset_service.rb @@ -0,0 +1,21 @@ +require "bigdecimal" + +module CodingInterview + module Application + module Service + module AssetService + module_function + + def evaluate_stock(stock, prices) + price = prices[stock.symbol] + raise "missing price for #{stock.symbol}" if price.nil? + stock.qty * price + end + + def total_valuation(account, prices) + account.stocks.inject(account.cash) { |acc, e| acc + evaluate_stock(e, prices) } + end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/service/portfolio_service.rb b/ruby/lib/coding_interview/application/service/portfolio_service.rb new file mode 100644 index 0000000..d3d9ef2 --- /dev/null +++ b/ruby/lib/coding_interview/application/service/portfolio_service.rb @@ -0,0 +1,74 @@ +require "bigdecimal" +require_relative "asset_service" +require_relative "../../domain/app_constants" +require_relative "../../domain/stock" + +module CodingInterview + module Application + module Service + module PortfolioService + module_function + + def floor2(x); x.floor(2); end + def floor0(x); x.floor(0); end + + def price_of(prices, symbol) + price = prices[symbol] + raise "missing price for #{symbol}" if price.nil? + price + end + + # Allocate a brand-new account given a contribution amount. + def allocate_new(amount, portfolio, prices) + cash_from_rate = floor0(amount * Domain::AppConstants::CASH_RATE) + investable = amount - cash_from_rate + stocks = portfolio.items.map do |item| + price = price_of(prices, item.symbol) + qty = floor2(investable * item.rate / price) + Domain::Stock.new(item.symbol, qty) + end + used_for_stocks = stocks.inject(BigDecimal("0")) { |acc, e| acc + e.qty * price_of(prices, e.symbol) } + residual = investable - used_for_stocks + Domain::Account.new(cash_from_rate + residual, stocks) + end + + # Additional contribution: only buy (no sell). Residual is kept in cash. + def allocate_additional(account, amount, portfolio, prices) + total_after = AssetService.total_valuation(account, prices) + amount + target_cash = floor0(total_after * Domain::AppConstants::CASH_RATE) + investable = total_after - target_cash + current_qty = account.stocks.each_with_object({}) { |e, h| h[e.symbol] = e.qty } + + portfolio_symbols = portfolio.items.map(&:symbol) + new_portfolio_stocks = portfolio.items.map do |item| + price = price_of(prices, item.symbol) + target_qty = floor2(investable * item.rate / price) + current = current_qty.fetch(item.symbol, BigDecimal("0")) + final_qty = target_qty > current ? target_qty : current + Domain::Stock.new(item.symbol, final_qty) + end + preserved_stocks = account.stocks.reject { |e| portfolio_symbols.include?(e.symbol) } + all_stocks = new_portfolio_stocks + preserved_stocks + + final_valuation = all_stocks.inject(BigDecimal("0")) { |acc, e| acc + e.qty * price_of(prices, e.symbol) } + final_cash = total_after - final_valuation + Domain::Account.new(final_cash, all_stocks) + end + + # Rebalance: re-allocate qty per portfolio target (buy and sell). + def rebalance(account, portfolio, prices) + # XXX this implementation might not be correct + investable = AssetService.total_valuation(account, prices) + new_stocks = portfolio.items.map do |item| + price = price_of(prices, item.symbol) + qty = floor2(investable * item.rate / price) + Domain::Stock.new(item.symbol, qty) + end + final_valuation = new_stocks.inject(BigDecimal("0")) { |acc, e| acc + e.qty * price_of(prices, e.symbol) } + final_cash = investable - final_valuation + Domain::Account.new(final_cash, new_stocks) + end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/usecase/asset/get_asset_usecase.rb b/ruby/lib/coding_interview/application/usecase/asset/get_asset_usecase.rb new file mode 100644 index 0000000..b8696cb --- /dev/null +++ b/ruby/lib/coding_interview/application/usecase/asset/get_asset_usecase.rb @@ -0,0 +1,33 @@ +require_relative "../../service/asset_service" + +module CodingInterview + module Application + module Usecase + module Asset + GetAssetUsecaseInput = Struct.new(:user_id) + GetAssetStockOutput = Struct.new(:symbol, :evaluation_amount) + GetAssetUsecaseOutput = Struct.new(:cash_amount, :stocks) + + class GetAssetUsecaseException < StandardError; end + class UserNotFound < GetAssetUsecaseException; end + + class GetAssetUsecase + def initialize(account_repository, market_price_repository) + @account_repository = account_repository + @market_price_repository = market_price_repository + end + + def run(input) + account = @account_repository.find(input.user_id) + raise UserNotFound if account.nil? + prices = @market_price_repository.all + stocks = account.stocks.map do |e| + GetAssetStockOutput.new(e.symbol, Service::AssetService.evaluate_stock(e, prices)) + end + GetAssetUsecaseOutput.new(account.cash, stocks) + end + end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/usecase/market_price/update_market_price_usecase.rb b/ruby/lib/coding_interview/application/usecase/market_price/update_market_price_usecase.rb new file mode 100644 index 0000000..1c0e706 --- /dev/null +++ b/ruby/lib/coding_interview/application/usecase/market_price/update_market_price_usecase.rb @@ -0,0 +1,21 @@ +module CodingInterview + module Application + module Usecase + module MarketPrice + UpdateMarketPriceItemInput = Struct.new(:symbol, :market_price) + UpdateMarketPriceUsecaseInput = Struct.new(:items) + + class UpdateMarketPriceUsecase + def initialize(market_price_repository) + @market_price_repository = market_price_repository + end + + def run(input) + prices = input.items.each_with_object({}) { |i, h| h[i.symbol] = i.market_price } + @market_price_repository.update(prices) + end + end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/usecase/order/additional_buy_order_usecase.rb b/ruby/lib/coding_interview/application/usecase/order/additional_buy_order_usecase.rb new file mode 100644 index 0000000..3a9a51a --- /dev/null +++ b/ruby/lib/coding_interview/application/usecase/order/additional_buy_order_usecase.rb @@ -0,0 +1,35 @@ +require_relative "../../service/portfolio_service" +require_relative "../../../domain/app_constants" + +module CodingInterview + module Application + module Usecase + module Order + AdditionalBuyOrderUsecaseInput = Struct.new(:user_id, :amount) + + class AdditionalBuyOrderUsecaseException < StandardError; end + class AdditionalBuyUserNotFound < AdditionalBuyOrderUsecaseException; end + class AdditionalBuyAmountTooSmall < AdditionalBuyOrderUsecaseException; end + + class AdditionalBuyOrderUsecase + def initialize(account_repository, portfolio_repository, market_price_repository) + @account_repository = account_repository + @portfolio_repository = portfolio_repository + @market_price_repository = market_price_repository + end + + def run(input) + raise AdditionalBuyAmountTooSmall if input.amount < Domain::AppConstants::MIN_OPERATION_AMOUNT + account = @account_repository.find(input.user_id) + raise AdditionalBuyUserNotFound if account.nil? + + portfolio = @portfolio_repository.get + prices = @market_price_repository.all + updated = Service::PortfolioService.allocate_additional(account, input.amount, portfolio, prices) + @account_repository.upsert(input.user_id, updated) + end + end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/usecase/order/new_contribution_order_usecase.rb b/ruby/lib/coding_interview/application/usecase/order/new_contribution_order_usecase.rb new file mode 100644 index 0000000..40d0ffa --- /dev/null +++ b/ruby/lib/coding_interview/application/usecase/order/new_contribution_order_usecase.rb @@ -0,0 +1,34 @@ +require_relative "../../service/portfolio_service" +require_relative "../../../domain/app_constants" + +module CodingInterview + module Application + module Usecase + module Order + NewContributionOrderUsecaseInput = Struct.new(:user_id, :amount) + + class NewContributionOrderUsecaseException < StandardError; end + class UserAlreadyExists < NewContributionOrderUsecaseException; end + class NewContributionAmountTooSmall < NewContributionOrderUsecaseException; end + + class NewContributionOrderUsecase + def initialize(account_repository, portfolio_repository, market_price_repository) + @account_repository = account_repository + @portfolio_repository = portfolio_repository + @market_price_repository = market_price_repository + end + + def run(input) + raise NewContributionAmountTooSmall if input.amount < Domain::AppConstants::MIN_OPERATION_AMOUNT + raise UserAlreadyExists if @account_repository.exists?(input.user_id) + + portfolio = @portfolio_repository.get + prices = @market_price_repository.all + account = Service::PortfolioService.allocate_new(input.amount, portfolio, prices) + @account_repository.upsert(input.user_id, account) + end + end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/usecase/order/rebalance_order_usecase.rb b/ruby/lib/coding_interview/application/usecase/order/rebalance_order_usecase.rb new file mode 100644 index 0000000..c1eb676 --- /dev/null +++ b/ruby/lib/coding_interview/application/usecase/order/rebalance_order_usecase.rb @@ -0,0 +1,32 @@ +require_relative "../../service/portfolio_service" + +module CodingInterview + module Application + module Usecase + module Order + RebalanceOrderUsecaseInput = Struct.new(:user_id) + + class RebalanceOrderUsecaseException < StandardError; end + class RebalanceUserNotFound < RebalanceOrderUsecaseException; end + + class RebalanceOrderUsecase + def initialize(account_repository, portfolio_repository, market_price_repository) + @account_repository = account_repository + @portfolio_repository = portfolio_repository + @market_price_repository = market_price_repository + end + + def run(input) + account = @account_repository.find(input.user_id) + raise RebalanceUserNotFound if account.nil? + + portfolio = @portfolio_repository.get + prices = @market_price_repository.all + updated = Service::PortfolioService.rebalance(account, portfolio, prices) + @account_repository.upsert(input.user_id, updated) + end + end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/usecase/portfolio/get_latest_portfolio_usecase.rb b/ruby/lib/coding_interview/application/usecase/portfolio/get_latest_portfolio_usecase.rb new file mode 100644 index 0000000..340f6ec --- /dev/null +++ b/ruby/lib/coding_interview/application/usecase/portfolio/get_latest_portfolio_usecase.rb @@ -0,0 +1,23 @@ +module CodingInterview + module Application + module Usecase + module Portfolio + GetLatestPortfolioItemOutput = Struct.new(:symbol, :rate) + GetLatestPortfolioUsecaseOutput = Struct.new(:items) + + class GetLatestPortfolioUsecase + def initialize(portfolio_repository) + @portfolio_repository = portfolio_repository + end + + def run + p = @portfolio_repository.get + GetLatestPortfolioUsecaseOutput.new( + p.items.map { |i| GetLatestPortfolioItemOutput.new(i.symbol, i.rate) } + ) + end + end + end + end + end +end diff --git a/ruby/lib/coding_interview/application/usecase/portfolio/update_portfolio_usecase.rb b/ruby/lib/coding_interview/application/usecase/portfolio/update_portfolio_usecase.rb new file mode 100644 index 0000000..acfbfe0 --- /dev/null +++ b/ruby/lib/coding_interview/application/usecase/portfolio/update_portfolio_usecase.rb @@ -0,0 +1,39 @@ +require_relative "../../../domain/stock" + +module CodingInterview + module Application + module Usecase + module Portfolio + UpdatePortfolioItemInput = Struct.new(:symbol, :rate) + UpdatePortfolioUsecaseInput = Struct.new(:items) + + class UpdatePortfolioUsecaseException < StandardError; end + class InvalidPortfolio < UpdatePortfolioUsecaseException + def initialize(reason) + super(reason) + @reason = reason + end + attr_reader :reason + end + + class UpdatePortfolioUsecase + def initialize(portfolio_repository) + @portfolio_repository = portfolio_repository + end + + def run(input) + portfolio = + begin + Domain::Portfolio.new( + input.items.map { |i| Domain::PortfolioItem.new(i.symbol, i.rate) } + ) + rescue ArgumentError => e + raise InvalidPortfolio.new(e.message) + end + @portfolio_repository.update(portfolio) + end + end + end + end + end +end diff --git a/ruby/lib/coding_interview/domain/app_constants.rb b/ruby/lib/coding_interview/domain/app_constants.rb new file mode 100644 index 0000000..806af05 --- /dev/null +++ b/ruby/lib/coding_interview/domain/app_constants.rb @@ -0,0 +1,24 @@ +require "bigdecimal" +require_relative "stock" +require_relative "stock_symbol" + +module CodingInterview + module Domain + module AppConstants + CASH_RATE = BigDecimal("0.05") + MIN_OPERATION_AMOUNT = BigDecimal("10000") + + SUPPORTED_SYMBOLS = [StockSymbol::Toyopa, StockSymbol::Somy].freeze + + INITIAL_PRICES = { + StockSymbol::Toyopa => BigDecimal("4.2135"), + StockSymbol::Somy => BigDecimal("1.2345") + }.freeze + + INITIAL_PORTFOLIO = Portfolio.new([ + PortfolioItem.new(StockSymbol::Toyopa, BigDecimal("0.40")), + PortfolioItem.new(StockSymbol::Somy, BigDecimal("0.60")) + ]) + end + end +end diff --git a/ruby/lib/coding_interview/domain/stock.rb b/ruby/lib/coding_interview/domain/stock.rb new file mode 100644 index 0000000..81159e4 --- /dev/null +++ b/ruby/lib/coding_interview/domain/stock.rb @@ -0,0 +1,21 @@ +module CodingInterview + module Domain + Stock = Struct.new(:symbol, :qty) + PortfolioItem = Struct.new(:symbol, :rate) + + class Portfolio + attr_reader :items + + def initialize(items) + raise ArgumentError, "portfolio must have at least one item" if items.empty? + total = items.map(&:rate).inject(BigDecimal("0")) { |acc, r| acc + r } + raise ArgumentError, "portfolio rates must sum to 1, got #{total}" unless total == BigDecimal("1") + symbols = items.map(&:symbol) + raise ArgumentError, "portfolio must not have duplicate symbols" if symbols.uniq.size != symbols.size + @items = items.freeze + end + end + + Account = Struct.new(:cash, :stocks) + end +end diff --git a/ruby/lib/coding_interview/domain/stock_symbol.rb b/ruby/lib/coding_interview/domain/stock_symbol.rb new file mode 100644 index 0000000..e1bd2c3 --- /dev/null +++ b/ruby/lib/coding_interview/domain/stock_symbol.rb @@ -0,0 +1,17 @@ +module CodingInterview + module Domain + module StockSymbol + Toyopa = :Toyopa + Somy = :Somy + + ALL = [Toyopa, Somy].freeze + + def self.from_string(s) + case s + when "Toyopa" then Toyopa + when "Somy" then Somy + end + end + end + end +end diff --git a/ruby/lib/coding_interview/domain/user_id.rb b/ruby/lib/coding_interview/domain/user_id.rb new file mode 100644 index 0000000..39dc1c8 --- /dev/null +++ b/ruby/lib/coding_interview/domain/user_id.rb @@ -0,0 +1,25 @@ +module CodingInterview + module Domain + class UserId + attr_reader :value + + def initialize(value) + raise ArgumentError, "userId must not be empty" if value.nil? || value.empty? + @value = value + end + + def ==(other) + other.is_a?(UserId) && other.value == @value + end + alias eql? == + + def hash + @value.hash + end + + def to_s + @value + end + end + end +end diff --git a/ruby/lib/coding_interview/infrastructure/repository/account_repository_impl.rb b/ruby/lib/coding_interview/infrastructure/repository/account_repository_impl.rb new file mode 100644 index 0000000..25a2d30 --- /dev/null +++ b/ruby/lib/coding_interview/infrastructure/repository/account_repository_impl.rb @@ -0,0 +1,27 @@ +require_relative "../../application/repository/account_repository" + +module CodingInterview + module Infrastructure + module Repository + class AccountRepositoryImpl < Application::Repository::AccountRepository + def initialize + @store = {} + @mutex = Mutex.new + end + + def find(user_id) + @mutex.synchronize { @store[user_id.value] } + end + + def upsert(user_id, account) + @mutex.synchronize { @store[user_id.value] = account } + nil + end + + def exists?(user_id) + @mutex.synchronize { @store.key?(user_id.value) } + end + end + end + end +end diff --git a/ruby/lib/coding_interview/infrastructure/repository/market_price_repository_impl.rb b/ruby/lib/coding_interview/infrastructure/repository/market_price_repository_impl.rb new file mode 100644 index 0000000..91f78d8 --- /dev/null +++ b/ruby/lib/coding_interview/infrastructure/repository/market_price_repository_impl.rb @@ -0,0 +1,24 @@ +require_relative "../../application/repository/market_price_repository" +require_relative "../../domain/app_constants" + +module CodingInterview + module Infrastructure + module Repository + class MarketPriceRepositoryImpl < Application::Repository::MarketPriceRepository + def initialize + @prices = Domain::AppConstants::INITIAL_PRICES.dup + @mutex = Mutex.new + end + + def all + @mutex.synchronize { @prices.dup } + end + + def update(prices) + @mutex.synchronize { @prices = prices.dup } + nil + end + end + end + end +end diff --git a/ruby/lib/coding_interview/infrastructure/repository/portfolio_repository_impl.rb b/ruby/lib/coding_interview/infrastructure/repository/portfolio_repository_impl.rb new file mode 100644 index 0000000..c3ea171 --- /dev/null +++ b/ruby/lib/coding_interview/infrastructure/repository/portfolio_repository_impl.rb @@ -0,0 +1,24 @@ +require_relative "../../application/repository/portfolio_repository" +require_relative "../../domain/app_constants" + +module CodingInterview + module Infrastructure + module Repository + class PortfolioRepositoryImpl < Application::Repository::PortfolioRepository + def initialize + @portfolio = Domain::AppConstants::INITIAL_PORTFOLIO + @mutex = Mutex.new + end + + def get + @mutex.synchronize { @portfolio } + end + + def update(portfolio) + @mutex.synchronize { @portfolio = portfolio } + nil + end + end + end + end +end diff --git a/ruby/lib/coding_interview/infrastructure/server/dummy_server.rb b/ruby/lib/coding_interview/infrastructure/server/dummy_server.rb new file mode 100644 index 0000000..47f78fb --- /dev/null +++ b/ruby/lib/coding_interview/infrastructure/server/dummy_server.rb @@ -0,0 +1,52 @@ +require_relative "../repository/account_repository_impl" +require_relative "../repository/market_price_repository_impl" +require_relative "../repository/portfolio_repository_impl" +require_relative "../../application/usecase/asset/get_asset_usecase" +require_relative "../../application/usecase/portfolio/get_latest_portfolio_usecase" +require_relative "../../application/usecase/portfolio/update_portfolio_usecase" +require_relative "../../application/usecase/market_price/update_market_price_usecase" +require_relative "../../application/usecase/order/new_contribution_order_usecase" +require_relative "../../application/usecase/order/additional_buy_order_usecase" +require_relative "../../application/usecase/order/rebalance_order_usecase" +require_relative "../../presentation/asset_controller" +require_relative "../../presentation/portfolio_controller" +require_relative "../../presentation/order_controller" +require_relative "../../presentation/market_price_controller" + +module CodingInterview + module Infrastructure + module Server + class DummyServer + attr_reader :asset_controller, :portfolio_controller, :order_controller, :market_price_controller + + def initialize(asset_controller, portfolio_controller, order_controller, market_price_controller) + @asset_controller = asset_controller + @portfolio_controller = portfolio_controller + @order_controller = order_controller + @market_price_controller = market_price_controller + end + + def self.default + portfolio_repository = Repository::PortfolioRepositoryImpl.new + account_repository = Repository::AccountRepositoryImpl.new + market_price_repository = Repository::MarketPriceRepositoryImpl.new + + get_asset_usecase = Application::Usecase::Asset::GetAssetUsecase.new(account_repository, market_price_repository) + get_latest_portfolio_usecase = Application::Usecase::Portfolio::GetLatestPortfolioUsecase.new(portfolio_repository) + update_portfolio_usecase = Application::Usecase::Portfolio::UpdatePortfolioUsecase.new(portfolio_repository) + update_market_price_usecase = Application::Usecase::MarketPrice::UpdateMarketPriceUsecase.new(market_price_repository) + new_contribution_order_usecase = Application::Usecase::Order::NewContributionOrderUsecase.new(account_repository, portfolio_repository, market_price_repository) + additional_buy_order_usecase = Application::Usecase::Order::AdditionalBuyOrderUsecase.new(account_repository, portfolio_repository, market_price_repository) + rebalance_order_usecase = Application::Usecase::Order::RebalanceOrderUsecase.new(account_repository, portfolio_repository, market_price_repository) + + asset_controller = Presentation::AssetController.new(get_asset_usecase) + portfolio_controller = Presentation::PortfolioController.new(get_latest_portfolio_usecase, update_portfolio_usecase) + order_controller = Presentation::OrderController.new(new_contribution_order_usecase, additional_buy_order_usecase, rebalance_order_usecase) + market_price_controller = Presentation::MarketPriceController.new(update_market_price_usecase) + + new(asset_controller, portfolio_controller, order_controller, market_price_controller) + end + end + end + end +end diff --git a/ruby/lib/coding_interview/infrastructure/server/main.rb b/ruby/lib/coding_interview/infrastructure/server/main.rb new file mode 100644 index 0000000..54d2b32 --- /dev/null +++ b/ruby/lib/coding_interview/infrastructure/server/main.rb @@ -0,0 +1,4 @@ +require_relative "dummy_server" + +CodingInterview::Infrastructure::Server::DummyServer.default +puts "DummyServer initialized." diff --git a/ruby/lib/coding_interview/presentation/asset_controller.rb b/ruby/lib/coding_interview/presentation/asset_controller.rb new file mode 100644 index 0000000..8ce375b --- /dev/null +++ b/ruby/lib/coding_interview/presentation/asset_controller.rb @@ -0,0 +1,33 @@ +require_relative "presentation_exception" +require_relative "presentation_preparation" +require_relative "../application/usecase/asset/get_asset_usecase" + +module CodingInterview + module Presentation + StockDto = Struct.new(:symbol, :evaluation_amount) + GetAssetRequest = Struct.new(:user_id) + GetAssetResponse = Struct.new(:cash_amount, :stocks) + + class AssetController + include PresentationPreparation + + def initialize(get_asset_usecase) + @get_asset_usecase = get_asset_usecase + end + + def get_asset(req) + uid = parse_user_id(req.user_id) + out = + begin + @get_asset_usecase.run(Application::Usecase::Asset::GetAssetUsecaseInput.new(uid)) + rescue Application::Usecase::Asset::UserNotFound + raise BadRequestException.new("user not found") + end + GetAssetResponse.new( + out.cash_amount.to_s("F"), + out.stocks.map { |e| StockDto.new(e.symbol.to_s, e.evaluation_amount.to_s("F")) } + ) + end + end + end +end diff --git a/ruby/lib/coding_interview/presentation/market_price_controller.rb b/ruby/lib/coding_interview/presentation/market_price_controller.rb new file mode 100644 index 0000000..1a5c254 --- /dev/null +++ b/ruby/lib/coding_interview/presentation/market_price_controller.rb @@ -0,0 +1,34 @@ +require "bigdecimal" +require_relative "presentation_exception" +require_relative "../domain/stock_symbol" +require_relative "../application/usecase/market_price/update_market_price_usecase" + +module CodingInterview + module Presentation + MarketPriceItemDto = Struct.new(:symbol, :market_price) + UpdateMarketPriceRequest = Struct.new(:market_prices) + + class MarketPriceController + def initialize(update_market_price_usecase) + @update_market_price_usecase = update_market_price_usecase + end + + def update_market_price(req) + items = req.market_prices.map do |dto| + sym = Domain::StockSymbol.from_string(dto.symbol) + raise BadRequestException.new("unknown symbol: #{dto.symbol}") if sym.nil? + price = + begin + BigDecimal(dto.market_price) + rescue ArgumentError, TypeError + raise BadRequestException.new("invalid market_price: #{dto.market_price}") + end + Application::Usecase::MarketPrice::UpdateMarketPriceItemInput.new(sym, price) + end + @update_market_price_usecase.run( + Application::Usecase::MarketPrice::UpdateMarketPriceUsecaseInput.new(items) + ) + end + end + end +end diff --git a/ruby/lib/coding_interview/presentation/order_controller.rb b/ruby/lib/coding_interview/presentation/order_controller.rb new file mode 100644 index 0000000..032bb1e --- /dev/null +++ b/ruby/lib/coding_interview/presentation/order_controller.rb @@ -0,0 +1,56 @@ +require_relative "presentation_exception" +require_relative "presentation_preparation" +require_relative "../application/usecase/order/new_contribution_order_usecase" +require_relative "../application/usecase/order/additional_buy_order_usecase" +require_relative "../application/usecase/order/rebalance_order_usecase" + +module CodingInterview + module Presentation + NewContributionOrderRequest = Struct.new(:user_id, :amount) + AdditionalContributionOrderRequest = Struct.new(:user_id, :amount) + RebalanceOrderRequest = Struct.new(:user_id) + + class OrderController + include PresentationPreparation + + def initialize(new_contribution_order_usecase, additional_buy_order_usecase, rebalance_order_usecase) + @new_contribution_order_usecase = new_contribution_order_usecase + @additional_buy_order_usecase = additional_buy_order_usecase + @rebalance_order_usecase = rebalance_order_usecase + end + + def new_contribution_order(req) + uid = parse_user_id(req.user_id) + amt = parse_amount(req.amount) + @new_contribution_order_usecase.run( + Application::Usecase::Order::NewContributionOrderUsecaseInput.new(uid, amt) + ) + rescue Application::Usecase::Order::UserAlreadyExists + raise BadRequestException.new("user already has account") + rescue Application::Usecase::Order::NewContributionAmountTooSmall + raise BadRequestException.new("amount is too small") + end + + def additional_contribution_order(req) + uid = parse_user_id(req.user_id) + amt = parse_amount(req.amount) + @additional_buy_order_usecase.run( + Application::Usecase::Order::AdditionalBuyOrderUsecaseInput.new(uid, amt) + ) + rescue Application::Usecase::Order::AdditionalBuyUserNotFound + raise BadRequestException.new("user has no live account") + rescue Application::Usecase::Order::AdditionalBuyAmountTooSmall + raise BadRequestException.new("amount is too small") + end + + def rebalance_order(req) + uid = parse_user_id(req.user_id) + @rebalance_order_usecase.run( + Application::Usecase::Order::RebalanceOrderUsecaseInput.new(uid) + ) + rescue Application::Usecase::Order::RebalanceUserNotFound + raise BadRequestException.new("user has no live account") + end + end + end +end diff --git a/ruby/lib/coding_interview/presentation/portfolio_controller.rb b/ruby/lib/coding_interview/presentation/portfolio_controller.rb new file mode 100644 index 0000000..af8968d --- /dev/null +++ b/ruby/lib/coding_interview/presentation/portfolio_controller.rb @@ -0,0 +1,46 @@ +require "bigdecimal" +require_relative "presentation_exception" +require_relative "../domain/stock_symbol" +require_relative "../application/usecase/portfolio/get_latest_portfolio_usecase" +require_relative "../application/usecase/portfolio/update_portfolio_usecase" + +module CodingInterview + module Presentation + PortfolioItemDto = Struct.new(:symbol, :rate) + GetOptimalPortfolioResponse = Struct.new(:portfolios) + UpdateOptimalPortfolioRequest = Struct.new(:portfolios) + + class PortfolioController + def initialize(get_latest_portfolio_usecase, update_portfolio_usecase) + @get_latest_portfolio_usecase = get_latest_portfolio_usecase + @update_portfolio_usecase = update_portfolio_usecase + end + + def get_optimal_portfolio + out = @get_latest_portfolio_usecase.run + GetOptimalPortfolioResponse.new( + out.items.map { |i| PortfolioItemDto.new(i.symbol.to_s, i.rate.to_s("F")) } + ) + end + + def update_optimal_portfolio(req) + items = req.portfolios.map do |dto| + sym = Domain::StockSymbol.from_string(dto.symbol) + raise BadRequestException.new("unknown symbol: #{dto.symbol}") if sym.nil? + rate = + begin + BigDecimal(dto.rate) + rescue ArgumentError, TypeError + raise BadRequestException.new("invalid rate: #{dto.rate}") + end + Application::Usecase::Portfolio::UpdatePortfolioItemInput.new(sym, rate) + end + begin + @update_portfolio_usecase.run(Application::Usecase::Portfolio::UpdatePortfolioUsecaseInput.new(items)) + rescue Application::Usecase::Portfolio::InvalidPortfolio => e + raise BadRequestException.new(e.reason) + end + end + end + end +end diff --git a/ruby/lib/coding_interview/presentation/presentation_exception.rb b/ruby/lib/coding_interview/presentation/presentation_exception.rb new file mode 100644 index 0000000..7722079 --- /dev/null +++ b/ruby/lib/coding_interview/presentation/presentation_exception.rb @@ -0,0 +1,11 @@ +module CodingInterview + module Presentation + class PresentationException < StandardError; end + + class BadRequestException < PresentationException + def initialize(message) + super(message) + end + end + end +end diff --git a/ruby/lib/coding_interview/presentation/presentation_preparation.rb b/ruby/lib/coding_interview/presentation/presentation_preparation.rb new file mode 100644 index 0000000..d1b0cbe --- /dev/null +++ b/ruby/lib/coding_interview/presentation/presentation_preparation.rb @@ -0,0 +1,21 @@ +require "bigdecimal" +require_relative "presentation_exception" +require_relative "../domain/user_id" + +module CodingInterview + module Presentation + module PresentationPreparation + def parse_user_id(s) + Domain::UserId.new(s) + rescue ArgumentError => e + raise BadRequestException.new(e.message) + end + + def parse_amount(s) + BigDecimal(s) + rescue ArgumentError, TypeError + raise BadRequestException.new("invalid amount: #{s}") + end + end + end +end diff --git a/ruby/spec/optimal_portfolio_scenario_spec.rb b/ruby/spec/optimal_portfolio_scenario_spec.rb new file mode 100644 index 0000000..f8b6b32 --- /dev/null +++ b/ruby/spec/optimal_portfolio_scenario_spec.rb @@ -0,0 +1,33 @@ +require "bigdecimal" + +RSpec.describe "Optimal Portfolio Management" do + let(:server) { CodingInterview::Infrastructure::Server::DummyServer.default } + let(:pc) { server.portfolio_controller } + + it "最適ポートフォリオを更新・取得できる" do + pc.update_optimal_portfolio( + CodingInterview::Presentation::UpdateOptimalPortfolioRequest.new([ + CodingInterview::Presentation::PortfolioItemDto.new("Toyopa", "0.20"), + CodingInterview::Presentation::PortfolioItemDto.new("Somy", "0.80") + ]) + ) + + first = pc.get_optimal_portfolio + first_map = first.portfolios.each_with_object({}) { |p, h| h[p.symbol] = p.rate } + + expect(BigDecimal(first_map["Toyopa"])).to eq(BigDecimal("0.20")) + expect(BigDecimal(first_map["Somy"])).to eq(BigDecimal("0.80")) + + pc.update_optimal_portfolio( + CodingInterview::Presentation::UpdateOptimalPortfolioRequest.new([ + CodingInterview::Presentation::PortfolioItemDto.new("Toyopa", "0.40"), + CodingInterview::Presentation::PortfolioItemDto.new("Somy", "0.60") + ]) + ) + second = pc.get_optimal_portfolio + second_map = second.portfolios.each_with_object({}) { |p, h| h[p.symbol] = p.rate } + + expect(BigDecimal(second_map["Toyopa"])).to eq(BigDecimal("0.40")) + expect(BigDecimal(second_map["Somy"])).to eq(BigDecimal("0.60")) + end +end diff --git a/ruby/spec/order_scenario_spec.rb b/ruby/spec/order_scenario_spec.rb new file mode 100644 index 0000000..d98533c --- /dev/null +++ b/ruby/spec/order_scenario_spec.rb @@ -0,0 +1,99 @@ +require "bigdecimal" +require "securerandom" +require "set" + +RSpec.describe "Investment Operation" do + let(:server) { CodingInterview::Infrastructure::Server::DummyServer.default } + let(:ac) { server.asset_controller } + let(:pc) { server.portfolio_controller } + let(:oc) { server.order_controller } + let(:mp) { server.market_price_controller } + + before do + pc.update_optimal_portfolio( + CodingInterview::Presentation::UpdateOptimalPortfolioRequest.new([ + CodingInterview::Presentation::PortfolioItemDto.new("Toyopa", "0.40"), + CodingInterview::Presentation::PortfolioItemDto.new("Somy", "0.60") + ]) + ) + mp.update_market_price( + CodingInterview::Presentation::UpdateMarketPriceRequest.new([ + CodingInterview::Presentation::MarketPriceItemDto.new("Toyopa", "2.5"), + CodingInterview::Presentation::MarketPriceItemDto.new("Somy", "3.0") + ]) + ) + end + + it "新規拠出・追加拠出・リバランスの一連の操作が正しく機能する" do + user_id = SecureRandom.uuid + + # Given: 存在しないユーザーで資産を取得しようとする + expect { + ac.get_asset(CodingInterview::Presentation::GetAssetRequest.new(user_id)) + }.to raise_error(CodingInterview::Presentation::BadRequestException) + # Then: BadRequestException が返される + + # When: 最適ポートフォリオを Toyopa=40%, Somy=60% に更新する + pc.update_optimal_portfolio( + CodingInterview::Presentation::UpdateOptimalPortfolioRequest.new([ + CodingInterview::Presentation::PortfolioItemDto.new("Toyopa", "0.40"), + CodingInterview::Presentation::PortfolioItemDto.new("Somy", "0.60") + ]) + ) + + # And: 新規拠出を 100,000 円で注文する + oc.new_contribution_order( + CodingInterview::Presentation::NewContributionOrderRequest.new(user_id, "100000") + ) + + asset1 = ac.get_asset(CodingInterview::Presentation::GetAssetRequest.new(user_id)) + expect(asset1.stocks.map(&:symbol).to_set).to eq(Set["Toyopa", "Somy"]) + total1 = BigDecimal(asset1.cash_amount) + asset1.stocks.inject(BigDecimal("0")) { |a, e| a + BigDecimal(e.evaluation_amount) } + expect((total1 - BigDecimal("100000")).abs).to be <= BigDecimal("2") + + # Then: 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる + asset1_toyopa = asset1.stocks.find { |e| e.symbol == "Toyopa" } + asset1_somy = asset1.stocks.find { |e| e.symbol == "Somy" } + expect(BigDecimal(asset1_toyopa.evaluation_amount)).to eq(BigDecimal("38000")) + expect(BigDecimal(asset1_somy.evaluation_amount)).to eq(BigDecimal("57000")) + expect(BigDecimal(asset1.cash_amount)).to eq(BigDecimal("5000")) + + # When: 追加拠出を 100,000 円で注文する + oc.additional_contribution_order( + CodingInterview::Presentation::AdditionalContributionOrderRequest.new(user_id, "100000") + ) + + # Then: 資産合計が約 200,000 円になる + asset2 = ac.get_asset(CodingInterview::Presentation::GetAssetRequest.new(user_id)) + total2 = BigDecimal(asset2.cash_amount) + asset2.stocks.inject(BigDecimal("0")) { |a, e| a + BigDecimal(e.evaluation_amount) } + expect((total2 - BigDecimal("200000")).abs).to be <= BigDecimal("4") + + # And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる + asset2_toyopa = asset2.stocks.find { |e| e.symbol == "Toyopa" } + asset2_somy = asset2.stocks.find { |e| e.symbol == "Somy" } + expect(BigDecimal(asset2_toyopa.evaluation_amount)).to eq(BigDecimal("76000")) + expect(BigDecimal(asset2_somy.evaluation_amount)).to eq(BigDecimal("114000")) + expect(BigDecimal(asset2.cash_amount)).to eq(BigDecimal("10000")) + + # When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする + pc.update_optimal_portfolio( + CodingInterview::Presentation::UpdateOptimalPortfolioRequest.new([ + CodingInterview::Presentation::PortfolioItemDto.new("Toyopa", "0.10"), + CodingInterview::Presentation::PortfolioItemDto.new("Somy", "0.90") + ]) + ) + oc.rebalance_order(CodingInterview::Presentation::RebalanceOrderRequest.new(user_id)) + + # Then: リバランス後も資産合計がほぼ変わらない + asset3 = ac.get_asset(CodingInterview::Presentation::GetAssetRequest.new(user_id)) + total3 = BigDecimal(asset3.cash_amount) + asset3.stocks.inject(BigDecimal("0")) { |a, e| a + BigDecimal(e.evaluation_amount) } + expect((total3 - total2).abs).to be <= BigDecimal("4") + + # And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる + asset3_toyopa = asset3.stocks.find { |e| e.symbol == "Toyopa" } + asset3_somy = asset3.stocks.find { |e| e.symbol == "Somy" } + expect(BigDecimal(asset3_toyopa.evaluation_amount)).to eq(BigDecimal("19000")) + expect(BigDecimal(asset3_somy.evaluation_amount)).to eq(BigDecimal("171000")) + expect(BigDecimal(asset3.cash_amount)).to eq(BigDecimal("10000")) + end +end diff --git a/ruby/spec/spec_helper.rb b/ruby/spec/spec_helper.rb new file mode 100644 index 0000000..f6d24f7 --- /dev/null +++ b/ruby/spec/spec_helper.rb @@ -0,0 +1,3 @@ +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +require "coding_interview/infrastructure/server/dummy_server" diff --git a/scala/.gitignore b/scala/.gitignore new file mode 100644 index 0000000..36fe64c --- /dev/null +++ b/scala/.gitignore @@ -0,0 +1,12 @@ +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/4d602a24bc5e4bbc2b8cedf08d4e982a80a7dfea/Scala.gitignore + +*.class +*.log + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +target/ +.idea/ +.bsp/ diff --git a/scala/.scalafmt.conf b/scala/.scalafmt.conf new file mode 100644 index 0000000..45d3082 --- /dev/null +++ b/scala/.scalafmt.conf @@ -0,0 +1,17 @@ +version = 3.10.4 +runner.dialect = scala3 +maxColumn = 120 +rewrite.rules = [Imports, AvoidInfix, SortModifiers, ExpandImportSelectors] +rewrite.imports { + sort = ascii + contiguousGroups = "no" + groups = [[".*"]] +} +assumeStandardLibraryStripMargin = true +project.excludeFilters = [] +project.layout = StandardConvention +newlines.topLevelStatementBlankLines = [ + { maxNest = 0, blanks = 1, regex = "Import" } +] +rewrite.scala3.convertToNewSyntax = true +rewrite.scala3.newSyntax.control = false diff --git a/scala/README.md b/scala/README.md new file mode 100644 index 0000000..10de320 --- /dev/null +++ b/scala/README.md @@ -0,0 +1,65 @@ +# サンプルラップサービス + +## 開発 + +NOTE: java21未満を利用する場合は `project/build.properties` を変更して sbt `1.11.7` などを利用してください + +```shell +# 準備 +git init && git add . && git commit -m init +sbt test:compile + +# テスト実行 +sbt test +``` + +## サービス概要 + +このアプリケーションはロボアドバイザーサービスのバックエンドです。 + +### 株と評価額 + +- 株には株数(qty)があります(例: 1株、2株) +- 各株には1株あたりの市場価格があります(例: 1株あたり100円) +- 例: 顧客が5株保有している場合、評価額は `5株 × 100円 = 500円` となります + +### ロボアドバイザーサービス + +- **顧客の口座** + - 新規拠出を行うと、口座がすぐに開きます + - 口座の中で資産を管理することになります +- **顧客の資産** + - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します + - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円をいくつかの株で保有する + - 株は価格で保持するのではなく、株数で保持します + - そのため、市場価格に応じて評価額は変わることになります +- **最適ポートフォリオ** + - サービスが管理する、株の評価額ベースの構成比率 + - 例: A株を評価額の30%B株を評価額の70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30% B株95万円*70% になるように努める + - 購入時・売却時・リバランス時には、売買後の資産比率が現在の最適ポートフォリオに近づく形での売買を実施します +- **株の売買** + - 本アプリケーションでは、注文APIを叩くと即時必要な株の売買が成立し資産に反映出来るものとします +- 用語 + - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 + - 全売却注文: 運用中の株を全て売却すること。 + - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + +## 確認観点 + +- 成果を出すこと +- 成果物についての理解・責任を持つこと + +## 課題 + +以下の課題をAIを用いて、あなた自身の言葉・実装で回答してください。 + +1. API/アーキテクチャについてAIと協力しながら自分の言葉で説明してください +2. テストが全て通るようにしてください + - まずは現状のテストを走らせて、どのような内容で落ちているかを説明してください + - また、実装バグがあるため、テストコードは一切変更せず、AIと協力しながら実装を修正してください + - その上で、修正内容について説明をしてください +3. 全売却APIを実装してください + - 全売却後の現金の取り扱いに関しての方針を決めてください + - ストレッチ: 売却後の現金は銀行APIへ連携 +4. (部分売却APIを実装してください) diff --git a/scala/build.sbt b/scala/build.sbt new file mode 100644 index 0000000..e39e2dc --- /dev/null +++ b/scala/build.sbt @@ -0,0 +1,10 @@ +val scala3Version = "3.8.3" + +lazy val root = project + .in(file(".")) + .settings( + name := "folio-coding-interview", + version := "0.1.0-SNAPSHOT", + scalaVersion := scala3Version, + libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.20" % Test + ) diff --git a/scala/project/build.properties b/scala/project/build.properties new file mode 100644 index 0000000..df061f4 --- /dev/null +++ b/scala/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.12.9 diff --git a/scala/project/plugins.sbt b/scala/project/plugins.sbt new file mode 100644 index 0000000..eefc0dd --- /dev/null +++ b/scala/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.6") diff --git a/scala/src/main/scala/folio/codinginterview/application/repository/AccountRepository.scala b/scala/src/main/scala/folio/codinginterview/application/repository/AccountRepository.scala new file mode 100644 index 0000000..de7a992 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/repository/AccountRepository.scala @@ -0,0 +1,12 @@ +package folio.codinginterview.application.repository + +import folio.codinginterview.domain.Account +import folio.codinginterview.domain.UserId +import scala.concurrent.Future + +/** 口座管理リポジトリ。 */ +trait AccountRepository { + def find(userId: UserId): Future[Option[Account]] + def upsert(userId: UserId, account: Account): Future[Unit] + def exists(userId: UserId): Future[Boolean] +} diff --git a/scala/src/main/scala/folio/codinginterview/application/repository/MarketPriceRepository.scala b/scala/src/main/scala/folio/codinginterview/application/repository/MarketPriceRepository.scala new file mode 100644 index 0000000..e048fad --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/repository/MarketPriceRepository.scala @@ -0,0 +1,10 @@ +package folio.codinginterview.application.repository + +import folio.codinginterview.domain.StockSymbol +import scala.concurrent.Future + +/** 市場価格リポジトリ。 */ +trait MarketPriceRepository { + def all(): Future[Map[StockSymbol, BigDecimal]] + def update(prices: Map[StockSymbol, BigDecimal]): Future[Unit] +} diff --git a/scala/src/main/scala/folio/codinginterview/application/repository/PortfolioRepository.scala b/scala/src/main/scala/folio/codinginterview/application/repository/PortfolioRepository.scala new file mode 100644 index 0000000..720ce55 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/repository/PortfolioRepository.scala @@ -0,0 +1,10 @@ +package folio.codinginterview.application.repository + +import folio.codinginterview.domain.Portfolio +import scala.concurrent.Future + +/** 最適ポートフォリオリポジトリ。 */ +trait PortfolioRepository { + def get(): Future[Portfolio] + def update(portfolio: Portfolio): Future[Unit] +} diff --git a/scala/src/main/scala/folio/codinginterview/application/service/AssetService.scala b/scala/src/main/scala/folio/codinginterview/application/service/AssetService.scala new file mode 100644 index 0000000..2d5eba1 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/service/AssetService.scala @@ -0,0 +1,19 @@ +package folio.codinginterview.application.service + +import folio.codinginterview.domain.Account +import folio.codinginterview.domain.Stock +import folio.codinginterview.domain.StockSymbol + +object AssetService { + def evaluateStock(stock: Stock, prices: Map[StockSymbol, BigDecimal]): BigDecimal = { + val price = prices.getOrElse( + stock.symbol, + throw new IllegalStateException(s"missing price for ${stock.symbol}") + ) + stock.qty * price + } + + def totalValuation(account: Account, prices: Map[StockSymbol, BigDecimal]): BigDecimal = { + account.stocks.map(e => evaluateStock(e, prices)).sum + account.cash + } +} diff --git a/scala/src/main/scala/folio/codinginterview/application/service/PortfolioService.scala b/scala/src/main/scala/folio/codinginterview/application/service/PortfolioService.scala new file mode 100644 index 0000000..f4e2cbc --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/service/PortfolioService.scala @@ -0,0 +1,80 @@ +package folio.codinginterview.application.service + +import folio.codinginterview.domain.Account +import folio.codinginterview.domain.AppConstants +import folio.codinginterview.domain.Stock +import folio.codinginterview.domain.StockSymbol +import folio.codinginterview.domain.Portfolio +import scala.math.BigDecimal.RoundingMode + +object PortfolioService { + private def floor2(x: BigDecimal): BigDecimal = x.setScale(2, RoundingMode.DOWN) + private def floor0(x: BigDecimal): BigDecimal = x.setScale(0, RoundingMode.DOWN) + private def priceOf(prices: Map[StockSymbol, BigDecimal], symbol: StockSymbol): BigDecimal = + prices.getOrElse(symbol, throw new IllegalStateException(s"missing price for $symbol")) + + /** Allocate a brand-new account given a contribution amount. */ + def allocateNew( + amount: BigDecimal, + portfolio: Portfolio, + prices: Map[StockSymbol, BigDecimal] + ): Account = { + val cashFromRate = floor0(amount * AppConstants.cashRate) + val investable = amount - cashFromRate + val stocks = portfolio.items.map { item => + val price = priceOf(prices, item.symbol) + val qty = floor2(investable * item.rate / price) + Stock(item.symbol, qty) + } + val usedForStocks = stocks.map(e => e.qty * priceOf(prices, e.symbol)).sum + val residual = investable - usedForStocks + Account(cash = cashFromRate + residual, stocks = stocks) + } + + /** Additional contribution: only buy (no sell). Residual is kept in cash. */ + def allocateAdditional( + account: Account, + amount: BigDecimal, + portfolio: Portfolio, + prices: Map[StockSymbol, BigDecimal] + ): Account = { + val totalAfter = AssetService.totalValuation(account, prices) + amount + val targetCash = floor0(totalAfter * AppConstants.cashRate) + val investable = totalAfter - targetCash + val currentQty: Map[StockSymbol, BigDecimal] = + account.stocks.map(e => e.symbol -> e.qty).toMap + + val portfolioSymbols = portfolio.items.map(_.symbol).toSet + val newPortfolioStocks = portfolio.items.map { item => + val price = priceOf(prices, item.symbol) + val targetQty = floor2(investable * item.rate / price) + val current = currentQty.getOrElse(item.symbol, BigDecimal(0)) + val finalQty = if (targetQty > current) targetQty else current + Stock(item.symbol, finalQty) + } + val preservedStocks = account.stocks.filterNot(e => portfolioSymbols.contains(e.symbol)) + val allStocks = newPortfolioStocks ++ preservedStocks + + val finalValuation = allStocks.map(e => e.qty * priceOf(prices, e.symbol)).sum + val finalCash = totalAfter - finalValuation + Account(cash = finalCash, stocks = allStocks) + } + + /** Rebalance: re-allocate qty per portfolio target (buy and sell). */ + def rebalance( + account: Account, + portfolio: Portfolio, + prices: Map[StockSymbol, BigDecimal] + ): Account = { + // XXX this implementation might not be correct + val investable = AssetService.totalValuation(account, prices) + val newStocks = portfolio.items.map { item => + val price = priceOf(prices, item.symbol) + val qty = floor2(investable * item.rate / price) + Stock(item.symbol, qty) + } + val finalValuation = newStocks.map(e => e.qty * priceOf(prices, e.symbol)).sum + val finalCash = investable - finalValuation + Account(cash = finalCash, stocks = newStocks) + } +} diff --git a/scala/src/main/scala/folio/codinginterview/application/usecase/asset/GetAssetUsecase.scala b/scala/src/main/scala/folio/codinginterview/application/usecase/asset/GetAssetUsecase.scala new file mode 100644 index 0000000..28a92e4 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/asset/GetAssetUsecase.scala @@ -0,0 +1,44 @@ +package folio.codinginterview.application.usecase.asset + +import folio.codinginterview.application.repository.AccountRepository +import folio.codinginterview.application.repository.MarketPriceRepository +import folio.codinginterview.application.service.AssetService +import folio.codinginterview.domain.StockSymbol +import folio.codinginterview.domain.UserId +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +final case class GetAssetUsecaseInput(userId: UserId) + +final case class GetAssetStockOutput(symbol: StockSymbol, evaluationAmount: BigDecimal) + +final case class GetAssetUsecaseOutput( + cashAmount: BigDecimal, + stocks: Seq[GetAssetStockOutput] +) + +sealed trait GetAssetUsecaseException extends RuntimeException +object GetAssetUsecaseException { + case object UserNotFound extends GetAssetUsecaseException +} + +final class GetAssetUsecase( + accountRepository: AccountRepository, + marketPriceRepository: MarketPriceRepository +)(using ec: ExecutionContext) { + def run(input: GetAssetUsecaseInput): Future[GetAssetUsecaseOutput] = { + for { + maybeAccount <- accountRepository.find(input.userId) + account <- maybeAccount match { + case Some(a) => Future.successful(a) + case None => Future.failed(GetAssetUsecaseException.UserNotFound) + } + prices <- marketPriceRepository.all() + } yield { + val stocks = account.stocks.map { e => + GetAssetStockOutput(e.symbol, AssetService.evaluateStock(e, prices)) + } + GetAssetUsecaseOutput(account.cash, stocks) + } + } +} diff --git a/scala/src/main/scala/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.scala b/scala/src/main/scala/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.scala new file mode 100644 index 0000000..e2da728 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.scala @@ -0,0 +1,17 @@ +package folio.codinginterview.application.usecase.market_price + +import folio.codinginterview.application.repository.MarketPriceRepository +import folio.codinginterview.domain.StockSymbol +import scala.concurrent.Future + +final case class UpdateMarketPriceItemInput(symbol: StockSymbol, marketPrice: BigDecimal) +final case class UpdateMarketPriceUsecaseInput(items: Seq[UpdateMarketPriceItemInput]) + +final class UpdateMarketPriceUsecase( + marketPriceRepository: MarketPriceRepository +) { + def run(input: UpdateMarketPriceUsecaseInput): Future[Unit] = { + val prices = input.items.map(i => i.symbol -> i.marketPrice).toMap + marketPriceRepository.update(prices) + } +} diff --git a/scala/src/main/scala/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.scala b/scala/src/main/scala/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.scala new file mode 100644 index 0000000..1fdf150 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.scala @@ -0,0 +1,42 @@ +package folio.codinginterview.application.usecase.order + +import folio.codinginterview.application.repository.AccountRepository +import folio.codinginterview.application.repository.MarketPriceRepository +import folio.codinginterview.application.repository.PortfolioRepository +import folio.codinginterview.application.service.PortfolioService +import folio.codinginterview.domain.AppConstants +import folio.codinginterview.domain.UserId +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +final case class AdditionalBuyOrderUsecaseInput(userId: UserId, amount: BigDecimal) + +sealed trait AdditionalBuyOrderUsecaseException extends RuntimeException +object AdditionalBuyOrderUsecaseException { + case object UserNotFound extends AdditionalBuyOrderUsecaseException + case object AmountTooSmall extends AdditionalBuyOrderUsecaseException +} + +final class AdditionalBuyOrderUsecase( + accountRepository: AccountRepository, + portfolioRepository: PortfolioRepository, + marketPriceRepository: MarketPriceRepository +)(using ec: ExecutionContext) { + def run(input: AdditionalBuyOrderUsecaseInput): Future[Unit] = { + if (input.amount < AppConstants.minOperationAmount) { + Future.failed(AdditionalBuyOrderUsecaseException.AmountTooSmall) + } else { + for { + maybeAccount <- accountRepository.find(input.userId) + account <- maybeAccount match { + case Some(a) => Future.successful(a) + case None => Future.failed(AdditionalBuyOrderUsecaseException.UserNotFound) + } + portfolio <- portfolioRepository.get() + prices <- marketPriceRepository.all() + updated = PortfolioService.allocateAdditional(account, input.amount, portfolio, prices) + _ <- accountRepository.upsert(input.userId, updated) + } yield () + } + } +} diff --git a/scala/src/main/scala/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.scala b/scala/src/main/scala/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.scala new file mode 100644 index 0000000..0cc94be --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.scala @@ -0,0 +1,41 @@ +package folio.codinginterview.application.usecase.order + +import folio.codinginterview.application.repository.AccountRepository +import folio.codinginterview.application.repository.MarketPriceRepository +import folio.codinginterview.application.repository.PortfolioRepository +import folio.codinginterview.application.service.PortfolioService +import folio.codinginterview.domain.AppConstants +import folio.codinginterview.domain.UserId +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +final case class NewContributionOrderUsecaseInput(userId: UserId, amount: BigDecimal) + +sealed trait NewContributionOrderUsecaseException extends RuntimeException +object NewContributionOrderUsecaseException { + case object UserAlreadyExists extends NewContributionOrderUsecaseException + case object AmountTooSmall extends NewContributionOrderUsecaseException +} + +final class NewContributionOrderUsecase( + accountRepository: AccountRepository, + portfolioRepository: PortfolioRepository, + marketPriceRepository: MarketPriceRepository +)(using ec: ExecutionContext) { + def run(input: NewContributionOrderUsecaseInput): Future[Unit] = { + if (input.amount < AppConstants.minOperationAmount) { + Future.failed(NewContributionOrderUsecaseException.AmountTooSmall) + } else { + for { + exists <- accountRepository.exists(input.userId) + _ <- + if (exists) Future.failed(NewContributionOrderUsecaseException.UserAlreadyExists) + else Future.unit + portfolio <- portfolioRepository.get() + prices <- marketPriceRepository.all() + account = PortfolioService.allocateNew(input.amount, portfolio, prices) + _ <- accountRepository.upsert(input.userId, account) + } yield () + } + } +} diff --git a/scala/src/main/scala/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.scala b/scala/src/main/scala/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.scala new file mode 100644 index 0000000..5a30164 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.scala @@ -0,0 +1,36 @@ +package folio.codinginterview.application.usecase.order + +import folio.codinginterview.application.repository.AccountRepository +import folio.codinginterview.application.repository.MarketPriceRepository +import folio.codinginterview.application.repository.PortfolioRepository +import folio.codinginterview.application.service.PortfolioService +import folio.codinginterview.domain.UserId +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +final case class RebalanceOrderUsecaseInput(userId: UserId) + +sealed trait RebalanceOrderUsecaseException extends RuntimeException +object RebalanceOrderUsecaseException { + case object UserNotFound extends RebalanceOrderUsecaseException +} + +final class RebalanceOrderUsecase( + accountRepository: AccountRepository, + portfolioRepository: PortfolioRepository, + marketPriceRepository: MarketPriceRepository +)(using ec: ExecutionContext) { + def run(input: RebalanceOrderUsecaseInput): Future[Unit] = { + for { + maybeAccount <- accountRepository.find(input.userId) + account <- maybeAccount match { + case Some(a) => Future.successful(a) + case None => Future.failed(RebalanceOrderUsecaseException.UserNotFound) + } + portfolio <- portfolioRepository.get() + prices <- marketPriceRepository.all() + updated = PortfolioService.rebalance(account, portfolio, prices) + _ <- accountRepository.upsert(input.userId, updated) + } yield () + } +} diff --git a/scala/src/main/scala/folio/codinginterview/application/usecase/portfolio/GetLatestPortfolioUsecase.scala b/scala/src/main/scala/folio/codinginterview/application/usecase/portfolio/GetLatestPortfolioUsecase.scala new file mode 100644 index 0000000..3dd214d --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/portfolio/GetLatestPortfolioUsecase.scala @@ -0,0 +1,27 @@ +package folio.codinginterview.application.usecase.portfolio + +import folio.codinginterview.application.repository.PortfolioRepository +import folio.codinginterview.domain.StockSymbol +import folio.codinginterview.domain.Portfolio +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +case object GetLatestPortfolioUsecaseInput + +final case class GetLatestPortfolioItemOutput(symbol: StockSymbol, rate: BigDecimal) + +final case class GetLatestPortfolioUsecaseOutput(items: Seq[GetLatestPortfolioItemOutput]) + +sealed trait GetLatestPortfolioUsecaseException extends RuntimeException + +final class GetLatestPortfolioUsecase( + portfolioRepository: PortfolioRepository +)(using ec: ExecutionContext) { + def run(): Future[GetLatestPortfolioUsecaseOutput] = { + portfolioRepository.get().map { p => + GetLatestPortfolioUsecaseOutput( + p.items.map(i => GetLatestPortfolioItemOutput(i.symbol, i.rate)) + ) + } + } +} diff --git a/scala/src/main/scala/folio/codinginterview/application/usecase/portfolio/UpdatePortfolioUsecase.scala b/scala/src/main/scala/folio/codinginterview/application/usecase/portfolio/UpdatePortfolioUsecase.scala new file mode 100644 index 0000000..a23efc0 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/portfolio/UpdatePortfolioUsecase.scala @@ -0,0 +1,29 @@ +package folio.codinginterview.application.usecase.portfolio + +import folio.codinginterview.application.repository.PortfolioRepository +import folio.codinginterview.domain.StockSymbol +import folio.codinginterview.domain.Portfolio +import folio.codinginterview.domain.PortfolioItem +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.util.control.NonFatal + +final case class UpdatePortfolioItemInput(symbol: StockSymbol, rate: BigDecimal) +final case class UpdatePortfolioUsecaseInput(items: Seq[UpdatePortfolioItemInput]) + +sealed trait UpdatePortfolioUsecaseException extends RuntimeException +object UpdatePortfolioUsecaseException { + final case class InvalidPortfolio(reason: String) extends UpdatePortfolioUsecaseException +} + +final class UpdatePortfolioUsecase( + portfolioRepository: PortfolioRepository +)(using ec: ExecutionContext) { + def run(input: UpdatePortfolioUsecaseInput): Future[Unit] = { + Future { + Portfolio(input.items.map(i => PortfolioItem(i.symbol, i.rate))) + }.recoverWith { case NonFatal(e) => + Future.failed(UpdatePortfolioUsecaseException.InvalidPortfolio(e.getMessage)) + }.flatMap(p => portfolioRepository.update(p)) + } +} diff --git a/scala/src/main/scala/folio/codinginterview/domain/AppConstants.scala b/scala/src/main/scala/folio/codinginterview/domain/AppConstants.scala new file mode 100644 index 0000000..7afe554 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/domain/AppConstants.scala @@ -0,0 +1,21 @@ +package folio.codinginterview.domain + +object AppConstants { + val cashRate: BigDecimal = BigDecimal("0.05") + + val minOperationAmount: BigDecimal = BigDecimal(10000) + + val supportedSymbols: Seq[StockSymbol] = Seq(StockSymbol.Toyopa, StockSymbol.Somy) + + val initialPrices: Map[StockSymbol, BigDecimal] = Map( + StockSymbol.Toyopa -> BigDecimal("4.2135"), + StockSymbol.Somy -> BigDecimal("1.2345") + ) + + val initialPortfolio: Portfolio = Portfolio( + Seq( + PortfolioItem(StockSymbol.Toyopa, BigDecimal("0.40")), + PortfolioItem(StockSymbol.Somy, BigDecimal("0.60")) + ) + ) +} diff --git a/scala/src/main/scala/folio/codinginterview/domain/Stock.scala b/scala/src/main/scala/folio/codinginterview/domain/Stock.scala new file mode 100644 index 0000000..503f8e6 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/domain/Stock.scala @@ -0,0 +1,19 @@ +package folio.codinginterview.domain + +final case class Stock(symbol: StockSymbol, qty: BigDecimal) + +final case class PortfolioItem(symbol: StockSymbol, rate: BigDecimal) + +final case class Portfolio(items: Seq[PortfolioItem]) { + require(items.nonEmpty, "portfolio must have at least one item") + require( + items.map(_.rate).sum == BigDecimal(1), + s"portfolio rates must sum to 1, got ${items.map(_.rate).sum}" + ) + require( + items.map(_.symbol).toSet.size == items.size, + "portfolio must not have duplicate symbols" + ) +} + +final case class Account(cash: BigDecimal, stocks: Seq[Stock]) diff --git a/scala/src/main/scala/folio/codinginterview/domain/StockSymbol.scala b/scala/src/main/scala/folio/codinginterview/domain/StockSymbol.scala new file mode 100644 index 0000000..da6c340 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/domain/StockSymbol.scala @@ -0,0 +1,13 @@ +package folio.codinginterview.domain + +enum StockSymbol { + case Toyopa, Somy +} + +object StockSymbol { + def fromString(s: String): Option[StockSymbol] = s match { + case "Toyopa" => Some(Toyopa) + case "Somy" => Some(Somy) + case _ => None + } +} diff --git a/scala/src/main/scala/folio/codinginterview/domain/UserId.scala b/scala/src/main/scala/folio/codinginterview/domain/UserId.scala new file mode 100644 index 0000000..9bd8216 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/domain/UserId.scala @@ -0,0 +1,5 @@ +package folio.codinginterview.domain + +final case class UserId(value: String) { + require(value.nonEmpty, "userId must not be empty") +} diff --git a/scala/src/main/scala/folio/codinginterview/infrastructure/repository/AccountRepositoryImpl.scala b/scala/src/main/scala/folio/codinginterview/infrastructure/repository/AccountRepositoryImpl.scala new file mode 100644 index 0000000..1e113ab --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/infrastructure/repository/AccountRepositoryImpl.scala @@ -0,0 +1,22 @@ +package folio.codinginterview.infrastructure.repository + +import folio.codinginterview.application.repository.AccountRepository +import folio.codinginterview.domain.Account +import folio.codinginterview.domain.UserId +import scala.collection.concurrent.TrieMap +import scala.concurrent.Future + +final class AccountRepositoryImpl extends AccountRepository { + private val store: TrieMap[String, Account] = TrieMap.empty + + override def find(userId: UserId): Future[Option[Account]] = + Future.successful(store.get(userId.value)) + + override def upsert(userId: UserId, account: Account): Future[Unit] = { + store.update(userId.value, account) + Future.unit + } + + override def exists(userId: UserId): Future[Boolean] = + Future.successful(store.contains(userId.value)) +} diff --git a/scala/src/main/scala/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.scala b/scala/src/main/scala/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.scala new file mode 100644 index 0000000..328a8f8 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.scala @@ -0,0 +1,19 @@ +package folio.codinginterview.infrastructure.repository + +import folio.codinginterview.application.repository.MarketPriceRepository +import folio.codinginterview.domain.AppConstants +import folio.codinginterview.domain.StockSymbol +import java.util.concurrent.atomic.AtomicReference +import scala.concurrent.Future + +final class MarketPriceRepositoryImpl extends MarketPriceRepository { + private val ref: AtomicReference[Map[StockSymbol, BigDecimal]] = + new AtomicReference(AppConstants.initialPrices) + + override def all(): Future[Map[StockSymbol, BigDecimal]] = Future.successful(ref.get()) + + override def update(prices: Map[StockSymbol, BigDecimal]): Future[Unit] = { + ref.set(prices) + Future.unit + } +} diff --git a/scala/src/main/scala/folio/codinginterview/infrastructure/repository/PortfolioRepositoryImpl.scala b/scala/src/main/scala/folio/codinginterview/infrastructure/repository/PortfolioRepositoryImpl.scala new file mode 100644 index 0000000..6ab1963 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/infrastructure/repository/PortfolioRepositoryImpl.scala @@ -0,0 +1,18 @@ +package folio.codinginterview.infrastructure.repository + +import folio.codinginterview.application.repository.PortfolioRepository +import folio.codinginterview.domain.AppConstants +import folio.codinginterview.domain.Portfolio +import java.util.concurrent.atomic.AtomicReference +import scala.concurrent.Future + +final class PortfolioRepositoryImpl extends PortfolioRepository { + private val ref: AtomicReference[Portfolio] = new AtomicReference(AppConstants.initialPortfolio) + + override def get(): Future[Portfolio] = Future.successful(ref.get()) + + override def update(portfolio: Portfolio): Future[Unit] = { + ref.set(portfolio) + Future.unit + } +} diff --git a/scala/src/main/scala/folio/codinginterview/infrastructure/server/DummyServer.scala b/scala/src/main/scala/folio/codinginterview/infrastructure/server/DummyServer.scala new file mode 100644 index 0000000..29e2d15 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/infrastructure/server/DummyServer.scala @@ -0,0 +1,66 @@ +package folio.codinginterview.infrastructure.server + +import folio.codinginterview.application.usecase.asset.GetAssetUsecase +import folio.codinginterview.application.usecase.market_price.UpdateMarketPriceUsecase +import folio.codinginterview.application.usecase.order.AdditionalBuyOrderUsecase +import folio.codinginterview.application.usecase.order.NewContributionOrderUsecase +import folio.codinginterview.application.usecase.order.RebalanceOrderUsecase +import folio.codinginterview.application.usecase.portfolio.GetLatestPortfolioUsecase +import folio.codinginterview.application.usecase.portfolio.UpdatePortfolioUsecase +import folio.codinginterview.infrastructure.repository.AccountRepositoryImpl +import folio.codinginterview.infrastructure.repository.MarketPriceRepositoryImpl +import folio.codinginterview.infrastructure.repository.PortfolioRepositoryImpl +import folio.codinginterview.presentation.AssetController +import folio.codinginterview.presentation.MarketPriceController +import folio.codinginterview.presentation.OrderController +import folio.codinginterview.presentation.PortfolioController +import scala.concurrent.ExecutionContext + +final class DummyServer( + val assetController: AssetController, + val portfolioController: PortfolioController, + val orderController: OrderController, + val marketPriceController: MarketPriceController +) + +object DummyServer { + def default()(using ec: ExecutionContext): DummyServer = { + val portfolioRepository = new PortfolioRepositoryImpl + val accountRepository = new AccountRepositoryImpl + val marketPriceRepository = new MarketPriceRepositoryImpl + + val getAssetUsecase = new GetAssetUsecase(accountRepository, marketPriceRepository) + val getLatestPortfolioUsecase = new GetLatestPortfolioUsecase(portfolioRepository) + val updatePortfolioUsecase = new UpdatePortfolioUsecase(portfolioRepository) + val updateMarketPriceUsecase = new UpdateMarketPriceUsecase(marketPriceRepository) + val newContributionOrderUsecase = new NewContributionOrderUsecase( + accountRepository, + portfolioRepository, + marketPriceRepository + ) + val additionalBuyOrderUsecase = new AdditionalBuyOrderUsecase( + accountRepository, + portfolioRepository, + marketPriceRepository + ) + val rebalanceOrderUsecase = new RebalanceOrderUsecase( + accountRepository, + portfolioRepository, + marketPriceRepository + ) + + val assetController = new AssetController(getAssetUsecase) + val portfolioController = new PortfolioController( + getLatestPortfolioUsecase, + updatePortfolioUsecase + ) + val orderController = new OrderController( + newContributionOrderUsecase, + additionalBuyOrderUsecase, + rebalanceOrderUsecase + ) + val marketPriceController = new MarketPriceController(updateMarketPriceUsecase) + + new DummyServer(assetController, portfolioController, orderController, marketPriceController) + } +} diff --git a/scala/src/main/scala/folio/codinginterview/infrastructure/server/Main.scala b/scala/src/main/scala/folio/codinginterview/infrastructure/server/Main.scala new file mode 100644 index 0000000..552e556 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/infrastructure/server/Main.scala @@ -0,0 +1,11 @@ +package folio.codinginterview.infrastructure.server + +import scala.concurrent.ExecutionContext + +object Main { + def main(args: Array[String]): Unit = { + given ExecutionContext = ExecutionContext.global + val _ = DummyServer.default() + println("DummyServer initialized.") + } +} diff --git a/scala/src/main/scala/folio/codinginterview/presentation/AssetController.scala b/scala/src/main/scala/folio/codinginterview/presentation/AssetController.scala new file mode 100644 index 0000000..ec4d20c --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/presentation/AssetController.scala @@ -0,0 +1,32 @@ +package folio.codinginterview.presentation + +import folio.codinginterview.application.usecase.asset.GetAssetUsecase +import folio.codinginterview.application.usecase.asset.GetAssetUsecaseException +import folio.codinginterview.application.usecase.asset.GetAssetUsecaseInput +import folio.codinginterview.presentation.PresentationException.BadRequestException +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +object AssetController { + final case class StockDto(symbol: String, evaluationAmount: String) + final case class GetAssetRequest(userId: String) + final case class GetAssetResponse(cashAmount: String, stocks: Seq[StockDto]) +} + +final class AssetController( + getAssetUsecase: GetAssetUsecase +)(using ec: ExecutionContext) + extends PresentationPreparation { + import AssetController.* + + def getAsset(req: GetAssetRequest): Future[GetAssetResponse] = + for { + uid <- parseUserId(req.userId) + out <- getAssetUsecase.run(GetAssetUsecaseInput(uid)).recoverWith { case GetAssetUsecaseException.UserNotFound => + Future.failed(BadRequestException("user not found")) + } + } yield GetAssetResponse( + cashAmount = out.cashAmount.toString, + stocks = out.stocks.map(e => StockDto(e.symbol.toString, e.evaluationAmount.toString)) + ) +} diff --git a/scala/src/main/scala/folio/codinginterview/presentation/MarketPriceController.scala b/scala/src/main/scala/folio/codinginterview/presentation/MarketPriceController.scala new file mode 100644 index 0000000..dd537a5 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/presentation/MarketPriceController.scala @@ -0,0 +1,38 @@ +package folio.codinginterview.presentation + +import folio.codinginterview.application.usecase.market_price.UpdateMarketPriceItemInput +import folio.codinginterview.application.usecase.market_price.UpdateMarketPriceUsecase +import folio.codinginterview.application.usecase.market_price.UpdateMarketPriceUsecaseInput +import folio.codinginterview.domain.StockSymbol +import folio.codinginterview.presentation.PresentationException.BadRequestException +import scala.concurrent.Future + +object MarketPriceController { + final case class MarketPriceItemDto(symbol: String, market_price: String) + final case class UpdateMarketPriceRequest(market_prices: Seq[MarketPriceItemDto]) +} + +final class MarketPriceController( + updateMarketPriceUsecase: UpdateMarketPriceUsecase +) { + import MarketPriceController.* + + def updateMarketPrice(req: UpdateMarketPriceRequest): Future[Unit] = { + val parsed: Either[String, Seq[UpdateMarketPriceItemInput]] = + req.market_prices.foldLeft[Either[String, Seq[UpdateMarketPriceItemInput]]](Right(Seq.empty)) { case (acc, dto) => + for { + xs <- acc + sym <- StockSymbol + .fromString(dto.symbol) + .toRight(s"unknown symbol: ${dto.symbol}") + price <- + try Right(BigDecimal(dto.market_price)) + catch { case _: Throwable => Left(s"invalid market_price: ${dto.market_price}") } + } yield xs :+ UpdateMarketPriceItemInput(sym, price) + } + parsed match { + case Left(msg) => Future.failed(BadRequestException(msg)) + case Right(items) => updateMarketPriceUsecase.run(UpdateMarketPriceUsecaseInput(items)) + } + } +} diff --git a/scala/src/main/scala/folio/codinginterview/presentation/OrderController.scala b/scala/src/main/scala/folio/codinginterview/presentation/OrderController.scala new file mode 100644 index 0000000..c1ba763 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/presentation/OrderController.scala @@ -0,0 +1,62 @@ +package folio.codinginterview.presentation + +import folio.codinginterview.application.usecase.order.AdditionalBuyOrderUsecase +import folio.codinginterview.application.usecase.order.AdditionalBuyOrderUsecaseException +import folio.codinginterview.application.usecase.order.AdditionalBuyOrderUsecaseInput +import folio.codinginterview.application.usecase.order.NewContributionOrderUsecase +import folio.codinginterview.application.usecase.order.NewContributionOrderUsecaseException +import folio.codinginterview.application.usecase.order.NewContributionOrderUsecaseInput +import folio.codinginterview.application.usecase.order.RebalanceOrderUsecase +import folio.codinginterview.application.usecase.order.RebalanceOrderUsecaseException +import folio.codinginterview.application.usecase.order.RebalanceOrderUsecaseInput +import folio.codinginterview.presentation.PresentationException.BadRequestException +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +object OrderController { + final case class NewContributionOrderRequest(userId: String, amount: String) + final case class AdditionalContributionOrderRequest(userId: String, amount: String) + final case class RebalanceOrderRequest(userId: String) +} + +final class OrderController( + newContributionOrderUsecase: NewContributionOrderUsecase, + additionalBuyOrderUsecase: AdditionalBuyOrderUsecase, + rebalanceOrderUsecase: RebalanceOrderUsecase +)(using ec: ExecutionContext) + extends PresentationPreparation { + import OrderController.* + + def newContributionOrder(req: NewContributionOrderRequest): Future[Unit] = + for { + uid <- parseUserId(req.userId) + amt <- parseAmount(req.amount) + _ <- newContributionOrderUsecase.run(NewContributionOrderUsecaseInput(uid, amt)).recoverWith { + case NewContributionOrderUsecaseException.UserAlreadyExists => + Future.failed(BadRequestException("user already has account")) + case NewContributionOrderUsecaseException.AmountTooSmall => + Future.failed(BadRequestException("amount is too small")) + } + } yield () + + def additionalContributionOrder(req: AdditionalContributionOrderRequest): Future[Unit] = + for { + uid <- parseUserId(req.userId) + amt <- parseAmount(req.amount) + _ <- additionalBuyOrderUsecase.run(AdditionalBuyOrderUsecaseInput(uid, amt)).recoverWith { + case AdditionalBuyOrderUsecaseException.UserNotFound => + Future.failed(BadRequestException("user has no live account")) + case AdditionalBuyOrderUsecaseException.AmountTooSmall => + Future.failed(BadRequestException("amount is too small")) + } + } yield () + + def rebalanceOrder(req: RebalanceOrderRequest): Future[Unit] = + for { + uid <- parseUserId(req.userId) + _ <- rebalanceOrderUsecase.run(RebalanceOrderUsecaseInput(uid)).recoverWith { + case RebalanceOrderUsecaseException.UserNotFound => + Future.failed(BadRequestException("user has no live account")) + } + } yield () +} diff --git a/scala/src/main/scala/folio/codinginterview/presentation/PortfolioController.scala b/scala/src/main/scala/folio/codinginterview/presentation/PortfolioController.scala new file mode 100644 index 0000000..5b0796c --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/presentation/PortfolioController.scala @@ -0,0 +1,55 @@ +package folio.codinginterview.presentation + +import folio.codinginterview.application.usecase.portfolio.GetLatestPortfolioUsecase +import folio.codinginterview.application.usecase.portfolio.UpdatePortfolioItemInput +import folio.codinginterview.application.usecase.portfolio.UpdatePortfolioUsecase +import folio.codinginterview.application.usecase.portfolio.UpdatePortfolioUsecaseException +import folio.codinginterview.application.usecase.portfolio.UpdatePortfolioUsecaseInput +import folio.codinginterview.domain.StockSymbol +import folio.codinginterview.presentation.PresentationException.BadRequestException +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +object PortfolioController { + final case class PortfolioItemDto(symbol: String, rate: String) + final case class GetOptimalPortfolioResponse(portfolios: Seq[PortfolioItemDto]) + final case class UpdateOptimalPortfolioRequest(portfolios: Seq[PortfolioItemDto]) +} + +final class PortfolioController( + getLatestPortfolioUsecase: GetLatestPortfolioUsecase, + updatePortfolioUsecase: UpdatePortfolioUsecase +)(using ec: ExecutionContext) { + import PortfolioController.* + + def getOptimalPortfolio(): Future[GetOptimalPortfolioResponse] = { + getLatestPortfolioUsecase.run().map { out => + GetOptimalPortfolioResponse( + out.items.map(i => PortfolioItemDto(i.symbol.toString, i.rate.toString)) + ) + } + } + + def updateOptimalPortfolio(req: UpdateOptimalPortfolioRequest): Future[Unit] = { + val parsed: Either[String, Seq[UpdatePortfolioItemInput]] = + req.portfolios.foldLeft[Either[String, Seq[UpdatePortfolioItemInput]]](Right(Seq.empty)) { case (acc, dto) => + for { + xs <- acc + sym <- StockSymbol + .fromString(dto.symbol) + .toRight(s"unknown symbol: ${dto.symbol}") + rate <- + try Right(BigDecimal(dto.rate)) + catch { case _: Throwable => Left(s"invalid rate: ${dto.rate}") } + } yield xs :+ UpdatePortfolioItemInput(sym, rate) + } + parsed match { + case Left(msg) => Future.failed(BadRequestException(msg)) + case Right(items) => + updatePortfolioUsecase.run(UpdatePortfolioUsecaseInput(items)).recoverWith { + case UpdatePortfolioUsecaseException.InvalidPortfolio(reason) => + Future.failed(BadRequestException(reason)) + } + } + } +} diff --git a/scala/src/main/scala/folio/codinginterview/presentation/PresentationException.scala b/scala/src/main/scala/folio/codinginterview/presentation/PresentationException.scala new file mode 100644 index 0000000..eb6aeca --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/presentation/PresentationException.scala @@ -0,0 +1,9 @@ +package folio.codinginterview.presentation + +sealed trait PresentationException extends RuntimeException + +object PresentationException { + final case class BadRequestException(message: String) extends PresentationException { + override def getMessage: String = message + } +} diff --git a/scala/src/main/scala/folio/codinginterview/presentation/PresentationPreparation.scala b/scala/src/main/scala/folio/codinginterview/presentation/PresentationPreparation.scala new file mode 100644 index 0000000..bde077b --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/presentation/PresentationPreparation.scala @@ -0,0 +1,16 @@ +package folio.codinginterview.presentation + +import folio.codinginterview.domain.UserId +import folio.codinginterview.presentation.PresentationException.BadRequestException +import scala.concurrent.Future +import scala.util.control.NonFatal + +trait PresentationPreparation { + protected def parseUserId(s: String): Future[UserId] = + try Future.successful(UserId(s)) + catch { case NonFatal(e) => Future.failed(BadRequestException(e.getMessage)) } + + protected def parseAmount(s: String): Future[BigDecimal] = + try Future.successful(BigDecimal(s)) + catch { case NonFatal(_) => Future.failed(BadRequestException(s"invalid amount: $s")) } +} diff --git a/scala/src/test/scala/folio/codinginterview/OptimalPortfolioScenario.scala b/scala/src/test/scala/folio/codinginterview/OptimalPortfolioScenario.scala new file mode 100644 index 0000000..85b9d0a --- /dev/null +++ b/scala/src/test/scala/folio/codinginterview/OptimalPortfolioScenario.scala @@ -0,0 +1,53 @@ +package folio.codinginterview + +import folio.codinginterview.infrastructure.server.DummyServer +import folio.codinginterview.presentation.PortfolioController.PortfolioItemDto +import folio.codinginterview.presentation.PortfolioController.UpdateOptimalPortfolioRequest +import org.scalatest.GivenWhenThen +import org.scalatest.featurespec.AnyFeatureSpec +import scala.concurrent.Await +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.concurrent.duration.* + +class OptimalPortfolioScenario extends AnyFeatureSpec with GivenWhenThen { + given ExecutionContext = ExecutionContext.global + + extension [A](f: Future[A]) def await: A = Await.result(f, 5.seconds) + + val server = DummyServer.default() + val pc = server.portfolioController + + Feature("Optimal Portfolio Management") { + Scenario("最適ポートフォリオを更新・取得できる") { + + Given("最適ポートフォリオを Toyopa=0.20, Somy=0.80 に更新する") + pc.updateOptimalPortfolio( + UpdateOptimalPortfolioRequest( + Seq(PortfolioItemDto("Toyopa", "0.20"), PortfolioItemDto("Somy", "0.80")) + ) + ).await + + When("最適ポートフォリオを取得する") + val first = pc.getOptimalPortfolio().await + val firstMap = first.portfolios.map(p => p.symbol -> p.rate).toMap + + Then("Toyopa=0.20, Somy=0.80 が返される") + assertResult(BigDecimal("0.20"))(BigDecimal(firstMap("Toyopa"))) + assertResult(BigDecimal("0.80"))(BigDecimal(firstMap("Somy"))) + + When("最適ポートフォリオを Toyopa=0.40, Somy=0.60 に更新して再取得する") + pc.updateOptimalPortfolio( + UpdateOptimalPortfolioRequest( + Seq(PortfolioItemDto("Toyopa", "0.40"), PortfolioItemDto("Somy", "0.60")) + ) + ).await + val second = pc.getOptimalPortfolio().await + val secondMap = second.portfolios.map(p => p.symbol -> p.rate).toMap + + Then("Toyopa=0.40, Somy=0.60 が返される") + assertResult(BigDecimal("0.40"))(BigDecimal(secondMap("Toyopa"))) + assertResult(BigDecimal("0.60"))(BigDecimal(secondMap("Somy"))) + } + } +} diff --git a/scala/src/test/scala/folio/codinginterview/OrderScenario.scala b/scala/src/test/scala/folio/codinginterview/OrderScenario.scala new file mode 100644 index 0000000..5ce518c --- /dev/null +++ b/scala/src/test/scala/folio/codinginterview/OrderScenario.scala @@ -0,0 +1,132 @@ +package folio.codinginterview + +import folio.codinginterview.infrastructure.server.DummyServer +import folio.codinginterview.presentation.AssetController.GetAssetRequest +import folio.codinginterview.presentation.MarketPriceController.MarketPriceItemDto +import folio.codinginterview.presentation.MarketPriceController.UpdateMarketPriceRequest +import folio.codinginterview.presentation.OrderController.AdditionalContributionOrderRequest +import folio.codinginterview.presentation.OrderController.NewContributionOrderRequest +import folio.codinginterview.presentation.OrderController.RebalanceOrderRequest +import folio.codinginterview.presentation.PortfolioController.PortfolioItemDto +import folio.codinginterview.presentation.PortfolioController.UpdateOptimalPortfolioRequest +import folio.codinginterview.presentation.PresentationException.BadRequestException +import java.util.UUID +import org.scalatest.BeforeAndAfterEach +import org.scalatest.GivenWhenThen +import org.scalatest.featurespec.AnyFeatureSpec +import scala.concurrent.Await +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.concurrent.duration.* + +class OrderScenario extends AnyFeatureSpec with GivenWhenThen with BeforeAndAfterEach { + given ExecutionContext = ExecutionContext.global + + extension [A](f: Future[A]) def await: A = Await.result(f, 5.seconds) + + val server = DummyServer.default() + val ac = server.assetController + val pc = server.portfolioController + val oc = server.orderController + val mp = server.marketPriceController + + override protected def beforeEach(): Unit = { + super.beforeEach() + + // initialize market price and optimal portfolio + pc.updateOptimalPortfolio( + UpdateOptimalPortfolioRequest( + Seq(PortfolioItemDto("Toyopa", "0.40"), PortfolioItemDto("Somy", "0.60")) + ) + ).await + mp.updateMarketPrice( + UpdateMarketPriceRequest( + Seq(MarketPriceItemDto("Toyopa", "2.5"), MarketPriceItemDto("Somy", "3.0")) + ) + ).await + } + + Feature("Investment Operation") { + Scenario("新規拠出・追加拠出・リバランスの一連の操作が正しく機能する") { + val userId = UUID.randomUUID().toString + + Given("存在しないユーザーで資産を取得しようとする") + val notFound = ac.getAsset(GetAssetRequest(userId)).failed.await + + Then("BadRequestException が返される") + assertResult(true)(notFound.isInstanceOf[BadRequestException]) + + When("最適ポートフォリオを Toyopa=40%, Somy=60% に更新する") + pc.updateOptimalPortfolio( + UpdateOptimalPortfolioRequest( + Seq(PortfolioItemDto("Toyopa", "0.40"), PortfolioItemDto("Somy", "0.60")) + ) + ).await + + And("新規拠出を 100,000 円で注文する") + oc.newContributionOrder(NewContributionOrderRequest(userId, "100000")).await + + val asset1 = ac.getAsset(GetAssetRequest(userId)).await + assertResult(Set("Toyopa", "Somy"))(asset1.stocks.map(_.symbol).toSet) + val total1 = BigDecimal(asset1.cashAmount) + asset1.stocks.map(e => BigDecimal(e.evaluationAmount)).sum + assertResult(true)((total1 - BigDecimal(100000)).abs <= BigDecimal(2)) + + Then("現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる") + // investable = 100000 - floor0(100000 * 0.05) = 95000 + val asset1Toyopa = asset1.stocks.find(_.symbol == "Toyopa").get + val asset1Somy = asset1.stocks.find(_.symbol == "Somy").get + assertResult(BigDecimal("38000"))( + BigDecimal(asset1Toyopa.evaluationAmount) + ) // floor2(95000 * 0.40 / 2.5) = 15200株 * 2.5 + assertResult(BigDecimal("57000"))( + BigDecimal(asset1Somy.evaluationAmount) + ) // floor2(95000 * 0.60 / 3.0) = 19000株 * 3.0 + assertResult(BigDecimal("5000"))(BigDecimal(asset1.cashAmount)) // 100000 - 38000 - 57000 + + When("追加拠出を 100,000 円で注文する") + oc.additionalContributionOrder(AdditionalContributionOrderRequest(userId, "100000")).await + + Then("資産合計が約 200,000 円になる") + val asset2 = ac.getAsset(GetAssetRequest(userId)).await + val total2 = BigDecimal(asset2.cashAmount) + asset2.stocks.map(e => BigDecimal(e.evaluationAmount)).sum + assertResult(true)((total2 - BigDecimal(200000)).abs <= BigDecimal(4)) + + And("現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる") + // totalAfter = 200000; investable = 200000 - floor0(200000 * 0.05) = 190000 + val asset2Toyopa = asset2.stocks.find(_.symbol == "Toyopa").get + val asset2Somy = asset2.stocks.find(_.symbol == "Somy").get + assertResult(BigDecimal("76000"))( + BigDecimal(asset2Toyopa.evaluationAmount) + ) // floor2(190000 * 0.40 / 2.5) = 30400株 * 2.5 + assertResult(BigDecimal("114000"))( + BigDecimal(asset2Somy.evaluationAmount) + ) // floor2(190000 * 0.60 / 3.0) = 38000株 * 3.0 + assertResult(BigDecimal("10000"))(BigDecimal(asset2.cashAmount)) // 200000 - 76000 - 114000 + + When("最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする") + pc.updateOptimalPortfolio( + UpdateOptimalPortfolioRequest( + Seq(PortfolioItemDto("Toyopa", "0.10"), PortfolioItemDto("Somy", "0.90")) + ) + ).await + oc.rebalanceOrder(RebalanceOrderRequest(userId)).await + + Then("リバランス後も資産合計がほぼ変わらない") + val asset3 = ac.getAsset(GetAssetRequest(userId)).await + val total3 = BigDecimal(asset3.cashAmount) + asset3.stocks.map(e => BigDecimal(e.evaluationAmount)).sum + assertResult(true)((total3 - total2).abs <= BigDecimal(4)) + + And("現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる") + // total = 200000; investable = 200000 - floor0(200000 * 0.05) = 190000 + val asset3Toyopa = asset3.stocks.find(_.symbol == "Toyopa").get + val asset3Somy = asset3.stocks.find(_.symbol == "Somy").get + assertResult(BigDecimal("19000"))( + BigDecimal(asset3Toyopa.evaluationAmount) + ) // floor2(190000 * 0.10 / 2.5) = 7600株 * 2.5 + assertResult(BigDecimal("171000"))( + BigDecimal(asset3Somy.evaluationAmount) + ) // floor2(190000 * 0.90 / 3.0) = 57000株 * 3.0 + assertResult(BigDecimal("10000"))(BigDecimal(asset3.cashAmount)) // 200000 - 19000 - 171000 + } + } +} diff --git a/typescript/.gitignore b/typescript/.gitignore new file mode 100644 index 0000000..3c25e1e --- /dev/null +++ b/typescript/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 0000000..a5dbc09 --- /dev/null +++ b/typescript/README.md @@ -0,0 +1,67 @@ +# サンプルラップサービス + +## 開発 + +- Node.js 20+ + +```shell +# 準備 +git init && git add . && git commit -m init + +# セットアップ +npm ci + +# テスト実行 +npm test +``` + +## サービス概要 + +このアプリケーションはロボアドバイザーサービスのバックエンドです。 + +### 株と評価額 + +- 株には株数(qty)があります(例: 1株、2株) +- 各株には1株あたりの市場価格があります(例: 1株あたり100円) +- 例: 顧客が5株保有している場合、評価額は `5株 × 100円 = 500円` となります + +### ロボアドバイザーサービス + +- **顧客の口座** + - 新規拠出を行うと、口座がすぐに開きます + - 口座の中で資産を管理することになります +- **顧客の資産** + - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します + - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円をいくつかの株で保有する + - 株は価格で保持するのではなく、株数で保持します + - そのため、市場価格に応じて評価額は変わることになります +- **最適ポートフォリオ** + - サービスが管理する、株の評価額ベースの構成比率 + - 例: A株を評価額の30%B株を評価額の70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30% B株95万円*70% になるように努める + - 購入時・売却時・リバランス時には、売買後の資産比率が現在の最適ポートフォリオに近づく形での売買を実施します +- **株の売買** + - 本アプリケーションでは、注文APIを叩くと即時必要な株の売買が成立し資産に反映出来るものとします +- 用語 + - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 + - 全売却注文: 運用中の株を全て売却すること。 + - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + +## 確認観点 + +- 成果を出すこと +- 成果物についての理解・責任を持つこと + +## 課題 + +以下の課題をAIを用いて、あなた自身の言葉・実装で回答してください。 + +1. API/アーキテクチャについてAIと協力しながら自分の言葉で説明してください +2. テストが全て通るようにしてください + - まずは現状のテストを走らせて、どのような内容で落ちているかを説明してください + - また、実装バグがあるため、テストコードは一切変更せず、AIと協力しながら実装を修正してください + - その上で、修正内容について説明をしてください +3. 全売却APIを実装してください + - 全売却後の現金の取り扱いに関しての方針を決めてください + - ストレッチ: 売却後の現金は銀行APIへ連携 +4. (部分売却APIを実装してください) diff --git a/typescript/package-lock.json b/typescript/package-lock.json new file mode 100644 index 0000000..b0649bb --- /dev/null +++ b/typescript/package-lock.json @@ -0,0 +1,2937 @@ +{ + "name": "coding-interview", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "coding-interview", + "version": "0.1.0", + "dependencies": { + "decimal.js": "^10.4.3" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "tsx": "^4.7.0", + "typescript": "^5.4.0", + "vitest": "^1.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/snapshot/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy/node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/utils/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/utils/node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/utils/node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@vitest/utils/node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/@vitest/utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chai/node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chai/node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chai/node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chai/node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chai/node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/chai/node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chai/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/execa/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vite-node/node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest/node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/why-is-node-running/node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/why-is-node-running/node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/typescript/package.json b/typescript/package.json new file mode 100644 index 0000000..d3be2f3 --- /dev/null +++ b/typescript/package.json @@ -0,0 +1,20 @@ +{ + "name": "coding-interview", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p .", + "start": "tsx src/infrastructure/server/main.ts", + "test": "vitest run" + }, + "dependencies": { + "decimal.js": "^10.4.3" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "tsx": "^4.7.0", + "typescript": "^5.4.0", + "vitest": "^1.6.0" + } +} diff --git a/typescript/src/application/repository/accountRepository.ts b/typescript/src/application/repository/accountRepository.ts new file mode 100644 index 0000000..ea17bbb --- /dev/null +++ b/typescript/src/application/repository/accountRepository.ts @@ -0,0 +1,9 @@ +import { Account } from "../../domain/stock.js"; +import { UserId } from "../../domain/userId.js"; + +/** 口座管理リポジトリ。 */ +export interface AccountRepository { + find(userId: UserId): Promise; + upsert(userId: UserId, account: Account): Promise; + exists(userId: UserId): Promise; +} diff --git a/typescript/src/application/repository/marketPriceRepository.ts b/typescript/src/application/repository/marketPriceRepository.ts new file mode 100644 index 0000000..6b17325 --- /dev/null +++ b/typescript/src/application/repository/marketPriceRepository.ts @@ -0,0 +1,8 @@ +import Decimal from "decimal.js"; +import { StockSymbol } from "../../domain/stockSymbol.js"; + +/** 市場価格リポジトリ。 */ +export interface MarketPriceRepository { + all(): Promise>; + update(prices: Map): Promise; +} diff --git a/typescript/src/application/repository/portfolioRepository.ts b/typescript/src/application/repository/portfolioRepository.ts new file mode 100644 index 0000000..0db9565 --- /dev/null +++ b/typescript/src/application/repository/portfolioRepository.ts @@ -0,0 +1,7 @@ +import { Portfolio } from "../../domain/stock.js"; + +/** 最適ポートフォリオリポジトリ。 */ +export interface PortfolioRepository { + get(): Promise; + update(portfolio: Portfolio): Promise; +} diff --git a/typescript/src/application/service/assetService.ts b/typescript/src/application/service/assetService.ts new file mode 100644 index 0000000..dbfee4f --- /dev/null +++ b/typescript/src/application/service/assetService.ts @@ -0,0 +1,20 @@ +import Decimal from "decimal.js"; +import { Account, Stock } from "../../domain/stock.js"; +import { StockSymbol } from "../../domain/stockSymbol.js"; + +export const AssetService = { + evaluateStock(stock: Stock, prices: Map): Decimal { + const price = prices.get(stock.symbol); + if (price === undefined) { + throw new Error(`missing price for ${stock.symbol}`); + } + return stock.qty.times(price); + }, + + totalValuation(account: Account, prices: Map): Decimal { + return account.stocks + .map((e) => AssetService.evaluateStock(e, prices)) + .reduce((acc, v) => acc.plus(v), new Decimal(0)) + .plus(account.cash); + }, +}; diff --git a/typescript/src/application/service/portfolioService.ts b/typescript/src/application/service/portfolioService.ts new file mode 100644 index 0000000..45ff375 --- /dev/null +++ b/typescript/src/application/service/portfolioService.ts @@ -0,0 +1,87 @@ +import Decimal from "decimal.js"; +import { AppConstants } from "../../domain/appConstants.js"; +import { Account, Stock, Portfolio } from "../../domain/stock.js"; +import { StockSymbol } from "../../domain/stockSymbol.js"; +import { AssetService } from "./assetService.js"; + +const floor2 = (x: Decimal): Decimal => x.toDecimalPlaces(2, Decimal.ROUND_DOWN); +const floor0 = (x: Decimal): Decimal => x.toDecimalPlaces(0, Decimal.ROUND_DOWN); +const priceOf = (prices: Map, symbol: StockSymbol): Decimal => { + const p = prices.get(symbol); + if (p === undefined) throw new Error(`missing price for ${symbol}`); + return p; +}; + +export const PortfolioService = { + /** Allocate a brand-new account given a contribution amount. */ + allocateNew( + amount: Decimal, + portfolio: Portfolio, + prices: Map, + ): Account { + const cashFromRate = floor0(amount.times(AppConstants.cashRate)); + const investable = amount.minus(cashFromRate); + const stocks: Stock[] = portfolio.items.map((item) => { + const price = priceOf(prices, item.symbol); + const qty = floor2(investable.times(item.rate).div(price)); + return { symbol: item.symbol, qty }; + }); + const usedForStocks = stocks + .map((e) => e.qty.times(priceOf(prices, e.symbol))) + .reduce((acc, v) => acc.plus(v), new Decimal(0)); + const residual = investable.minus(usedForStocks); + return { cash: cashFromRate.plus(residual), stocks }; + }, + + /** Additional contribution: only buy (no sell). Residual is kept in cash. */ + allocateAdditional( + account: Account, + amount: Decimal, + portfolio: Portfolio, + prices: Map, + ): Account { + const totalAfter = AssetService.totalValuation(account, prices).plus(amount); + const targetCash = floor0(totalAfter.times(AppConstants.cashRate)); + const investable = totalAfter.minus(targetCash); + const currentQty = new Map( + account.stocks.map((e) => [e.symbol, e.qty]), + ); + + const portfolioSymbols = new Set(portfolio.items.map((i) => i.symbol)); + const newPortfolioStocks: Stock[] = portfolio.items.map((item) => { + const price = priceOf(prices, item.symbol); + const targetQty = floor2(investable.times(item.rate).div(price)); + const current = currentQty.get(item.symbol) ?? new Decimal(0); + const finalQty = targetQty.greaterThan(current) ? targetQty : current; + return { symbol: item.symbol, qty: finalQty }; + }); + const preservedStocks = account.stocks.filter((e) => !portfolioSymbols.has(e.symbol)); + const allStocks = [...newPortfolioStocks, ...preservedStocks]; + + const finalValuation = allStocks + .map((e) => e.qty.times(priceOf(prices, e.symbol))) + .reduce((acc, v) => acc.plus(v), new Decimal(0)); + const finalCash = totalAfter.minus(finalValuation); + return { cash: finalCash, stocks: allStocks }; + }, + + /** Rebalance: re-allocate qty per portfolio target (buy and sell). */ + rebalance( + account: Account, + portfolio: Portfolio, + prices: Map, + ): Account { + // XXX this implementation might not be correct + const investable = AssetService.totalValuation(account, prices); + const newStocks: Stock[] = portfolio.items.map((item) => { + const price = priceOf(prices, item.symbol); + const qty = floor2(investable.times(item.rate).div(price)); + return { symbol: item.symbol, qty }; + }); + const finalValuation = newStocks + .map((e) => e.qty.times(priceOf(prices, e.symbol))) + .reduce((acc, v) => acc.plus(v), new Decimal(0)); + const finalCash = investable.minus(finalValuation); + return { cash: finalCash, stocks: newStocks }; + }, +}; diff --git a/typescript/src/application/usecase/asset/getAssetUsecase.ts b/typescript/src/application/usecase/asset/getAssetUsecase.ts new file mode 100644 index 0000000..e0bcd36 --- /dev/null +++ b/typescript/src/application/usecase/asset/getAssetUsecase.ts @@ -0,0 +1,47 @@ +import Decimal from "decimal.js"; +import { AccountRepository } from "../../repository/accountRepository.js"; +import { MarketPriceRepository } from "../../repository/marketPriceRepository.js"; +import { AssetService } from "../../service/assetService.js"; +import { StockSymbol } from "../../../domain/stockSymbol.js"; +import { UserId } from "../../../domain/userId.js"; + +export interface GetAssetUsecaseInput { + userId: UserId; +} + +export interface GetAssetStockOutput { + symbol: StockSymbol; + evaluationAmount: Decimal; +} + +export interface GetAssetUsecaseOutput { + cashAmount: Decimal; + stocks: GetAssetStockOutput[]; +} + +export class GetAssetUsecaseException extends Error {} +export class UserNotFoundException extends GetAssetUsecaseException { + constructor() { + super("user not found"); + } +} + +export class GetAssetUsecase { + constructor( + private readonly accountRepository: AccountRepository, + private readonly marketPriceRepository: MarketPriceRepository, + ) {} + + async run(input: GetAssetUsecaseInput): Promise { + const account = await this.accountRepository.find(input.userId); + if (account === undefined) { + throw new UserNotFoundException(); + } + const prices = await this.marketPriceRepository.all(); + const stocks = account.stocks.map((e) => ({ + symbol: e.symbol, + evaluationAmount: AssetService.evaluateStock(e, prices), + })); + return { cashAmount: account.cash, stocks }; + } +} diff --git a/typescript/src/application/usecase/market_price/updateMarketPriceUsecase.ts b/typescript/src/application/usecase/market_price/updateMarketPriceUsecase.ts new file mode 100644 index 0000000..19b9ea1 --- /dev/null +++ b/typescript/src/application/usecase/market_price/updateMarketPriceUsecase.ts @@ -0,0 +1,23 @@ +import Decimal from "decimal.js"; +import { MarketPriceRepository } from "../../repository/marketPriceRepository.js"; +import { StockSymbol } from "../../../domain/stockSymbol.js"; + +export interface UpdateMarketPriceItemInput { + symbol: StockSymbol; + marketPrice: Decimal; +} + +export interface UpdateMarketPriceUsecaseInput { + items: UpdateMarketPriceItemInput[]; +} + +export class UpdateMarketPriceUsecase { + constructor(private readonly marketPriceRepository: MarketPriceRepository) {} + + async run(input: UpdateMarketPriceUsecaseInput): Promise { + const prices = new Map( + input.items.map((i) => [i.symbol, i.marketPrice]), + ); + await this.marketPriceRepository.update(prices); + } +} diff --git a/typescript/src/application/usecase/order/additionalBuyOrderUsecase.ts b/typescript/src/application/usecase/order/additionalBuyOrderUsecase.ts new file mode 100644 index 0000000..df1e939 --- /dev/null +++ b/typescript/src/application/usecase/order/additionalBuyOrderUsecase.ts @@ -0,0 +1,46 @@ +import Decimal from "decimal.js"; +import { AccountRepository } from "../../repository/accountRepository.js"; +import { MarketPriceRepository } from "../../repository/marketPriceRepository.js"; +import { PortfolioRepository } from "../../repository/portfolioRepository.js"; +import { PortfolioService } from "../../service/portfolioService.js"; +import { AppConstants } from "../../../domain/appConstants.js"; +import { UserId } from "../../../domain/userId.js"; + +export interface AdditionalBuyOrderUsecaseInput { + userId: UserId; + amount: Decimal; +} + +export class AdditionalBuyOrderUsecaseException extends Error {} +export class AdditionalBuyUserNotFoundException extends AdditionalBuyOrderUsecaseException { + constructor() { + super("user not found"); + } +} +export class AdditionalBuyAmountTooSmallException extends AdditionalBuyOrderUsecaseException { + constructor() { + super("amount too small"); + } +} + +export class AdditionalBuyOrderUsecase { + constructor( + private readonly accountRepository: AccountRepository, + private readonly portfolioRepository: PortfolioRepository, + private readonly marketPriceRepository: MarketPriceRepository, + ) {} + + async run(input: AdditionalBuyOrderUsecaseInput): Promise { + if (input.amount.lessThan(AppConstants.minOperationAmount)) { + throw new AdditionalBuyAmountTooSmallException(); + } + const account = await this.accountRepository.find(input.userId); + if (account === undefined) { + throw new AdditionalBuyUserNotFoundException(); + } + const portfolio = await this.portfolioRepository.get(); + const prices = await this.marketPriceRepository.all(); + const updated = PortfolioService.allocateAdditional(account, input.amount, portfolio, prices); + await this.accountRepository.upsert(input.userId, updated); + } +} diff --git a/typescript/src/application/usecase/order/newContributionOrderUsecase.ts b/typescript/src/application/usecase/order/newContributionOrderUsecase.ts new file mode 100644 index 0000000..399952a --- /dev/null +++ b/typescript/src/application/usecase/order/newContributionOrderUsecase.ts @@ -0,0 +1,46 @@ +import Decimal from "decimal.js"; +import { AccountRepository } from "../../repository/accountRepository.js"; +import { MarketPriceRepository } from "../../repository/marketPriceRepository.js"; +import { PortfolioRepository } from "../../repository/portfolioRepository.js"; +import { PortfolioService } from "../../service/portfolioService.js"; +import { AppConstants } from "../../../domain/appConstants.js"; +import { UserId } from "../../../domain/userId.js"; + +export interface NewContributionOrderUsecaseInput { + userId: UserId; + amount: Decimal; +} + +export class NewContributionOrderUsecaseException extends Error {} +export class NewContributionUserAlreadyExistsException extends NewContributionOrderUsecaseException { + constructor() { + super("user already exists"); + } +} +export class NewContributionAmountTooSmallException extends NewContributionOrderUsecaseException { + constructor() { + super("amount too small"); + } +} + +export class NewContributionOrderUsecase { + constructor( + private readonly accountRepository: AccountRepository, + private readonly portfolioRepository: PortfolioRepository, + private readonly marketPriceRepository: MarketPriceRepository, + ) {} + + async run(input: NewContributionOrderUsecaseInput): Promise { + if (input.amount.lessThan(AppConstants.minOperationAmount)) { + throw new NewContributionAmountTooSmallException(); + } + const exists = await this.accountRepository.exists(input.userId); + if (exists) { + throw new NewContributionUserAlreadyExistsException(); + } + const portfolio = await this.portfolioRepository.get(); + const prices = await this.marketPriceRepository.all(); + const account = PortfolioService.allocateNew(input.amount, portfolio, prices); + await this.accountRepository.upsert(input.userId, account); + } +} diff --git a/typescript/src/application/usecase/order/rebalanceOrderUsecase.ts b/typescript/src/application/usecase/order/rebalanceOrderUsecase.ts new file mode 100644 index 0000000..4686e7c --- /dev/null +++ b/typescript/src/application/usecase/order/rebalanceOrderUsecase.ts @@ -0,0 +1,35 @@ +import { AccountRepository } from "../../repository/accountRepository.js"; +import { MarketPriceRepository } from "../../repository/marketPriceRepository.js"; +import { PortfolioRepository } from "../../repository/portfolioRepository.js"; +import { PortfolioService } from "../../service/portfolioService.js"; +import { UserId } from "../../../domain/userId.js"; + +export interface RebalanceOrderUsecaseInput { + userId: UserId; +} + +export class RebalanceOrderUsecaseException extends Error {} +export class RebalanceUserNotFoundException extends RebalanceOrderUsecaseException { + constructor() { + super("user not found"); + } +} + +export class RebalanceOrderUsecase { + constructor( + private readonly accountRepository: AccountRepository, + private readonly portfolioRepository: PortfolioRepository, + private readonly marketPriceRepository: MarketPriceRepository, + ) {} + + async run(input: RebalanceOrderUsecaseInput): Promise { + const account = await this.accountRepository.find(input.userId); + if (account === undefined) { + throw new RebalanceUserNotFoundException(); + } + const portfolio = await this.portfolioRepository.get(); + const prices = await this.marketPriceRepository.all(); + const updated = PortfolioService.rebalance(account, portfolio, prices); + await this.accountRepository.upsert(input.userId, updated); + } +} diff --git a/typescript/src/application/usecase/portfolio/getLatestPortfolioUsecase.ts b/typescript/src/application/usecase/portfolio/getLatestPortfolioUsecase.ts new file mode 100644 index 0000000..acb4540 --- /dev/null +++ b/typescript/src/application/usecase/portfolio/getLatestPortfolioUsecase.ts @@ -0,0 +1,23 @@ +import Decimal from "decimal.js"; +import { PortfolioRepository } from "../../repository/portfolioRepository.js"; +import { StockSymbol } from "../../../domain/stockSymbol.js"; + +export interface GetLatestPortfolioItemOutput { + symbol: StockSymbol; + rate: Decimal; +} + +export interface GetLatestPortfolioUsecaseOutput { + items: GetLatestPortfolioItemOutput[]; +} + +export class GetLatestPortfolioUsecase { + constructor(private readonly portfolioRepository: PortfolioRepository) {} + + async run(): Promise { + const p = await this.portfolioRepository.get(); + return { + items: p.items.map((i) => ({ symbol: i.symbol, rate: i.rate })), + }; + } +} diff --git a/typescript/src/application/usecase/portfolio/updatePortfolioUsecase.ts b/typescript/src/application/usecase/portfolio/updatePortfolioUsecase.ts new file mode 100644 index 0000000..1bae31a --- /dev/null +++ b/typescript/src/application/usecase/portfolio/updatePortfolioUsecase.ts @@ -0,0 +1,35 @@ +import Decimal from "decimal.js"; +import { PortfolioRepository } from "../../repository/portfolioRepository.js"; +import { Portfolio } from "../../../domain/stock.js"; +import { StockSymbol } from "../../../domain/stockSymbol.js"; + +export interface UpdatePortfolioItemInput { + symbol: StockSymbol; + rate: Decimal; +} + +export interface UpdatePortfolioUsecaseInput { + items: UpdatePortfolioItemInput[]; +} + +export class UpdatePortfolioUsecaseException extends Error {} +export class InvalidPortfolioException extends UpdatePortfolioUsecaseException { + constructor(reason: string) { + super(reason); + } +} + +export class UpdatePortfolioUsecase { + constructor(private readonly portfolioRepository: PortfolioRepository) {} + + async run(input: UpdatePortfolioUsecaseInput): Promise { + let portfolio: Portfolio; + try { + portfolio = new Portfolio(input.items.map((i) => ({ symbol: i.symbol, rate: i.rate }))); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new InvalidPortfolioException(msg); + } + await this.portfolioRepository.update(portfolio); + } +} diff --git a/typescript/src/domain/appConstants.ts b/typescript/src/domain/appConstants.ts new file mode 100644 index 0000000..8a41d9d --- /dev/null +++ b/typescript/src/domain/appConstants.ts @@ -0,0 +1,17 @@ +import Decimal from "decimal.js"; +import { StockSymbol } from "./stockSymbol.js"; +import { Portfolio } from "./stock.js"; + +export const AppConstants = { + cashRate: new Decimal("0.05"), + minOperationAmount: new Decimal(10000), + supportedSymbols: [StockSymbol.Toyopa, StockSymbol.Somy] as ReadonlyArray, + initialPrices: new Map([ + [StockSymbol.Toyopa, new Decimal("4.2135")], + [StockSymbol.Somy, new Decimal("1.2345")], + ]), + initialPortfolio: new Portfolio([ + { symbol: StockSymbol.Toyopa, rate: new Decimal("0.40") }, + { symbol: StockSymbol.Somy, rate: new Decimal("0.60") }, + ]), +}; diff --git a/typescript/src/domain/stock.ts b/typescript/src/domain/stock.ts new file mode 100644 index 0000000..5350ce0 --- /dev/null +++ b/typescript/src/domain/stock.ts @@ -0,0 +1,36 @@ +import Decimal from "decimal.js"; +import { StockSymbol } from "./stockSymbol.js"; + +export interface Stock { + symbol: StockSymbol; + qty: Decimal; +} + +export interface PortfolioItem { + symbol: StockSymbol; + rate: Decimal; +} + +export class Portfolio { + readonly items: ReadonlyArray; + + constructor(items: ReadonlyArray) { + if (items.length === 0) { + throw new Error("portfolio must have at least one item"); + } + const sum = items.reduce((acc, i) => acc.plus(i.rate), new Decimal(0)); + if (!sum.equals(1)) { + throw new Error(`portfolio rates must sum to 1, got ${sum.toString()}`); + } + const symbols = new Set(items.map((i) => i.symbol)); + if (symbols.size !== items.length) { + throw new Error("portfolio must not have duplicate symbols"); + } + this.items = items; + } +} + +export interface Account { + cash: Decimal; + stocks: ReadonlyArray; +} diff --git a/typescript/src/domain/stockSymbol.ts b/typescript/src/domain/stockSymbol.ts new file mode 100644 index 0000000..72af8c8 --- /dev/null +++ b/typescript/src/domain/stockSymbol.ts @@ -0,0 +1,11 @@ +export type StockSymbol = "Toyopa" | "Somy"; + +export const StockSymbol = { + Toyopa: "Toyopa" as StockSymbol, + Somy: "Somy" as StockSymbol, + + fromString(s: string): StockSymbol | undefined { + if (s === "Toyopa" || s === "Somy") return s; + return undefined; + }, +}; diff --git a/typescript/src/domain/userId.ts b/typescript/src/domain/userId.ts new file mode 100644 index 0000000..a76169d --- /dev/null +++ b/typescript/src/domain/userId.ts @@ -0,0 +1,10 @@ +export class UserId { + readonly value: string; + + constructor(value: string) { + if (value.length === 0) { + throw new Error("userId must not be empty"); + } + this.value = value; + } +} diff --git a/typescript/src/infrastructure/repository/accountRepositoryImpl.ts b/typescript/src/infrastructure/repository/accountRepositoryImpl.ts new file mode 100644 index 0000000..e40540b --- /dev/null +++ b/typescript/src/infrastructure/repository/accountRepositoryImpl.ts @@ -0,0 +1,19 @@ +import { AccountRepository } from "../../application/repository/accountRepository.js"; +import { Account } from "../../domain/stock.js"; +import { UserId } from "../../domain/userId.js"; + +export class AccountRepositoryImpl implements AccountRepository { + private readonly store: Map = new Map(); + + async find(userId: UserId): Promise { + return this.store.get(userId.value); + } + + async upsert(userId: UserId, account: Account): Promise { + this.store.set(userId.value, account); + } + + async exists(userId: UserId): Promise { + return this.store.has(userId.value); + } +} diff --git a/typescript/src/infrastructure/repository/marketPriceRepositoryImpl.ts b/typescript/src/infrastructure/repository/marketPriceRepositoryImpl.ts new file mode 100644 index 0000000..d06a47c --- /dev/null +++ b/typescript/src/infrastructure/repository/marketPriceRepositoryImpl.ts @@ -0,0 +1,16 @@ +import Decimal from "decimal.js"; +import { MarketPriceRepository } from "../../application/repository/marketPriceRepository.js"; +import { AppConstants } from "../../domain/appConstants.js"; +import { StockSymbol } from "../../domain/stockSymbol.js"; + +export class MarketPriceRepositoryImpl implements MarketPriceRepository { + private prices: Map = new Map(AppConstants.initialPrices); + + async all(): Promise> { + return new Map(this.prices); + } + + async update(prices: Map): Promise { + this.prices = new Map(prices); + } +} diff --git a/typescript/src/infrastructure/repository/portfolioRepositoryImpl.ts b/typescript/src/infrastructure/repository/portfolioRepositoryImpl.ts new file mode 100644 index 0000000..ab9886c --- /dev/null +++ b/typescript/src/infrastructure/repository/portfolioRepositoryImpl.ts @@ -0,0 +1,15 @@ +import { PortfolioRepository } from "../../application/repository/portfolioRepository.js"; +import { AppConstants } from "../../domain/appConstants.js"; +import { Portfolio } from "../../domain/stock.js"; + +export class PortfolioRepositoryImpl implements PortfolioRepository { + private portfolio: Portfolio = AppConstants.initialPortfolio; + + async get(): Promise { + return this.portfolio; + } + + async update(portfolio: Portfolio): Promise { + this.portfolio = portfolio; + } +} diff --git a/typescript/src/infrastructure/server/dummyServer.ts b/typescript/src/infrastructure/server/dummyServer.ts new file mode 100644 index 0000000..6c531fc --- /dev/null +++ b/typescript/src/infrastructure/server/dummyServer.ts @@ -0,0 +1,78 @@ +import { GetAssetUsecase } from "../../application/usecase/asset/getAssetUsecase.js"; +import { UpdateMarketPriceUsecase } from "../../application/usecase/market_price/updateMarketPriceUsecase.js"; +import { AdditionalBuyOrderUsecase } from "../../application/usecase/order/additionalBuyOrderUsecase.js"; +import { NewContributionOrderUsecase } from "../../application/usecase/order/newContributionOrderUsecase.js"; +import { RebalanceOrderUsecase } from "../../application/usecase/order/rebalanceOrderUsecase.js"; +import { GetLatestPortfolioUsecase } from "../../application/usecase/portfolio/getLatestPortfolioUsecase.js"; +import { UpdatePortfolioUsecase } from "../../application/usecase/portfolio/updatePortfolioUsecase.js"; +import { AssetController } from "../../presentation/assetController.js"; +import { MarketPriceController } from "../../presentation/marketPriceController.js"; +import { OrderController } from "../../presentation/orderController.js"; +import { PortfolioController } from "../../presentation/portfolioController.js"; +import { AccountRepositoryImpl } from "../repository/accountRepositoryImpl.js"; +import { MarketPriceRepositoryImpl } from "../repository/marketPriceRepositoryImpl.js"; +import { PortfolioRepositoryImpl } from "../repository/portfolioRepositoryImpl.js"; + +export class DummyServer { + readonly assetController: AssetController; + readonly portfolioController: PortfolioController; + readonly orderController: OrderController; + readonly marketPriceController: MarketPriceController; + + constructor( + assetController: AssetController, + portfolioController: PortfolioController, + orderController: OrderController, + marketPriceController: MarketPriceController, + ) { + this.assetController = assetController; + this.portfolioController = portfolioController; + this.orderController = orderController; + this.marketPriceController = marketPriceController; + } + + static default(): DummyServer { + const portfolioRepository = new PortfolioRepositoryImpl(); + const accountRepository = new AccountRepositoryImpl(); + const marketPriceRepository = new MarketPriceRepositoryImpl(); + + const getAssetUsecase = new GetAssetUsecase(accountRepository, marketPriceRepository); + const getLatestPortfolioUsecase = new GetLatestPortfolioUsecase(portfolioRepository); + const updatePortfolioUsecase = new UpdatePortfolioUsecase(portfolioRepository); + const updateMarketPriceUsecase = new UpdateMarketPriceUsecase(marketPriceRepository); + const newContributionOrderUsecase = new NewContributionOrderUsecase( + accountRepository, + portfolioRepository, + marketPriceRepository, + ); + const additionalBuyOrderUsecase = new AdditionalBuyOrderUsecase( + accountRepository, + portfolioRepository, + marketPriceRepository, + ); + const rebalanceOrderUsecase = new RebalanceOrderUsecase( + accountRepository, + portfolioRepository, + marketPriceRepository, + ); + + const assetController = new AssetController(getAssetUsecase); + const portfolioController = new PortfolioController( + getLatestPortfolioUsecase, + updatePortfolioUsecase, + ); + const orderController = new OrderController( + newContributionOrderUsecase, + additionalBuyOrderUsecase, + rebalanceOrderUsecase, + ); + const marketPriceController = new MarketPriceController(updateMarketPriceUsecase); + + return new DummyServer( + assetController, + portfolioController, + orderController, + marketPriceController, + ); + } +} diff --git a/typescript/src/infrastructure/server/main.ts b/typescript/src/infrastructure/server/main.ts new file mode 100644 index 0000000..88b7e05 --- /dev/null +++ b/typescript/src/infrastructure/server/main.ts @@ -0,0 +1,8 @@ +import { DummyServer } from "./dummyServer.js"; + +function main(): void { + DummyServer.default(); + console.log("DummyServer initialized."); +} + +main(); diff --git a/typescript/src/presentation/assetController.ts b/typescript/src/presentation/assetController.ts new file mode 100644 index 0000000..8852687 --- /dev/null +++ b/typescript/src/presentation/assetController.ts @@ -0,0 +1,43 @@ +import { + GetAssetUsecase, + UserNotFoundException, +} from "../application/usecase/asset/getAssetUsecase.js"; +import { BadRequestException } from "./presentationException.js"; +import { parseUserId } from "./presentationPreparation.js"; + +export interface StockDto { + symbol: string; + evaluationAmount: string; +} + +export interface GetAssetRequest { + userId: string; +} + +export interface GetAssetResponse { + cashAmount: string; + stocks: StockDto[]; +} + +export class AssetController { + constructor(private readonly getAssetUsecase: GetAssetUsecase) {} + + async getAsset(req: GetAssetRequest): Promise { + const uid = parseUserId(req.userId); + try { + const out = await this.getAssetUsecase.run({ userId: uid }); + return { + cashAmount: out.cashAmount.toString(), + stocks: out.stocks.map((e) => ({ + symbol: e.symbol, + evaluationAmount: e.evaluationAmount.toString(), + })), + }; + } catch (e) { + if (e instanceof UserNotFoundException) { + throw new BadRequestException("user not found"); + } + throw e; + } + } +} diff --git a/typescript/src/presentation/marketPriceController.ts b/typescript/src/presentation/marketPriceController.ts new file mode 100644 index 0000000..088875d --- /dev/null +++ b/typescript/src/presentation/marketPriceController.ts @@ -0,0 +1,38 @@ +import Decimal from "decimal.js"; +import { + UpdateMarketPriceItemInput, + UpdateMarketPriceUsecase, +} from "../application/usecase/market_price/updateMarketPriceUsecase.js"; +import { StockSymbol } from "../domain/stockSymbol.js"; +import { BadRequestException } from "./presentationException.js"; + +export interface MarketPriceItemDto { + symbol: string; + market_price: string; +} + +export interface UpdateMarketPriceRequest { + market_prices: MarketPriceItemDto[]; +} + +export class MarketPriceController { + constructor(private readonly updateMarketPriceUsecase: UpdateMarketPriceUsecase) {} + + async updateMarketPrice(req: UpdateMarketPriceRequest): Promise { + const items: UpdateMarketPriceItemInput[] = []; + for (const dto of req.market_prices) { + const sym = StockSymbol.fromString(dto.symbol); + if (sym === undefined) { + throw new BadRequestException(`unknown symbol: ${dto.symbol}`); + } + let price: Decimal; + try { + price = new Decimal(dto.market_price); + } catch { + throw new BadRequestException(`invalid market_price: ${dto.market_price}`); + } + items.push({ symbol: sym, marketPrice: price }); + } + await this.updateMarketPriceUsecase.run({ items }); + } +} diff --git a/typescript/src/presentation/orderController.ts b/typescript/src/presentation/orderController.ts new file mode 100644 index 0000000..8e322d1 --- /dev/null +++ b/typescript/src/presentation/orderController.ts @@ -0,0 +1,82 @@ +import { + AdditionalBuyAmountTooSmallException, + AdditionalBuyOrderUsecase, + AdditionalBuyUserNotFoundException, +} from "../application/usecase/order/additionalBuyOrderUsecase.js"; +import { + NewContributionAmountTooSmallException, + NewContributionOrderUsecase, + NewContributionUserAlreadyExistsException, +} from "../application/usecase/order/newContributionOrderUsecase.js"; +import { + RebalanceOrderUsecase, + RebalanceUserNotFoundException, +} from "../application/usecase/order/rebalanceOrderUsecase.js"; +import { BadRequestException } from "./presentationException.js"; +import { parseAmount, parseUserId } from "./presentationPreparation.js"; + +export interface NewContributionOrderRequest { + userId: string; + amount: string; +} + +export interface AdditionalContributionOrderRequest { + userId: string; + amount: string; +} + +export interface RebalanceOrderRequest { + userId: string; +} + +export class OrderController { + constructor( + private readonly newContributionOrderUsecase: NewContributionOrderUsecase, + private readonly additionalBuyOrderUsecase: AdditionalBuyOrderUsecase, + private readonly rebalanceOrderUsecase: RebalanceOrderUsecase, + ) {} + + async newContributionOrder(req: NewContributionOrderRequest): Promise { + const uid = parseUserId(req.userId); + const amt = parseAmount(req.amount); + try { + await this.newContributionOrderUsecase.run({ userId: uid, amount: amt }); + } catch (e) { + if (e instanceof NewContributionUserAlreadyExistsException) { + throw new BadRequestException("user already has account"); + } + if (e instanceof NewContributionAmountTooSmallException) { + throw new BadRequestException("amount is too small"); + } + throw e; + } + } + + async additionalContributionOrder(req: AdditionalContributionOrderRequest): Promise { + const uid = parseUserId(req.userId); + const amt = parseAmount(req.amount); + try { + await this.additionalBuyOrderUsecase.run({ userId: uid, amount: amt }); + } catch (e) { + if (e instanceof AdditionalBuyUserNotFoundException) { + throw new BadRequestException("user has no live account"); + } + if (e instanceof AdditionalBuyAmountTooSmallException) { + throw new BadRequestException("amount is too small"); + } + throw e; + } + } + + async rebalanceOrder(req: RebalanceOrderRequest): Promise { + const uid = parseUserId(req.userId); + try { + await this.rebalanceOrderUsecase.run({ userId: uid }); + } catch (e) { + if (e instanceof RebalanceUserNotFoundException) { + throw new BadRequestException("user has no live account"); + } + throw e; + } + } +} diff --git a/typescript/src/presentation/portfolioController.ts b/typescript/src/presentation/portfolioController.ts new file mode 100644 index 0000000..6d4685c --- /dev/null +++ b/typescript/src/presentation/portfolioController.ts @@ -0,0 +1,63 @@ +import Decimal from "decimal.js"; +import { + GetLatestPortfolioUsecase, +} from "../application/usecase/portfolio/getLatestPortfolioUsecase.js"; +import { + InvalidPortfolioException, + UpdatePortfolioItemInput, + UpdatePortfolioUsecase, +} from "../application/usecase/portfolio/updatePortfolioUsecase.js"; +import { StockSymbol } from "../domain/stockSymbol.js"; +import { BadRequestException } from "./presentationException.js"; + +export interface PortfolioItemDto { + symbol: string; + rate: string; +} + +export interface GetOptimalPortfolioResponse { + portfolios: PortfolioItemDto[]; +} + +export interface UpdateOptimalPortfolioRequest { + portfolios: PortfolioItemDto[]; +} + +export class PortfolioController { + constructor( + private readonly getLatestPortfolioUsecase: GetLatestPortfolioUsecase, + private readonly updatePortfolioUsecase: UpdatePortfolioUsecase, + ) {} + + async getOptimalPortfolio(): Promise { + const out = await this.getLatestPortfolioUsecase.run(); + return { + portfolios: out.items.map((i) => ({ symbol: i.symbol, rate: i.rate.toString() })), + }; + } + + async updateOptimalPortfolio(req: UpdateOptimalPortfolioRequest): Promise { + const items: UpdatePortfolioItemInput[] = []; + for (const dto of req.portfolios) { + const sym = StockSymbol.fromString(dto.symbol); + if (sym === undefined) { + throw new BadRequestException(`unknown symbol: ${dto.symbol}`); + } + let rate: Decimal; + try { + rate = new Decimal(dto.rate); + } catch { + throw new BadRequestException(`invalid rate: ${dto.rate}`); + } + items.push({ symbol: sym, rate }); + } + try { + await this.updatePortfolioUsecase.run({ items }); + } catch (e) { + if (e instanceof InvalidPortfolioException) { + throw new BadRequestException(e.message); + } + throw e; + } + } +} diff --git a/typescript/src/presentation/presentationException.ts b/typescript/src/presentation/presentationException.ts new file mode 100644 index 0000000..2654387 --- /dev/null +++ b/typescript/src/presentation/presentationException.ts @@ -0,0 +1,8 @@ +export class PresentationException extends Error {} + +export class BadRequestException extends PresentationException { + constructor(message: string) { + super(message); + this.name = "BadRequestException"; + } +} diff --git a/typescript/src/presentation/presentationPreparation.ts b/typescript/src/presentation/presentationPreparation.ts new file mode 100644 index 0000000..77b01cc --- /dev/null +++ b/typescript/src/presentation/presentationPreparation.ts @@ -0,0 +1,20 @@ +import Decimal from "decimal.js"; +import { UserId } from "../domain/userId.js"; +import { BadRequestException } from "./presentationException.js"; + +export function parseUserId(s: string): UserId { + try { + return new UserId(s); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new BadRequestException(msg); + } +} + +export function parseAmount(s: string): Decimal { + try { + return new Decimal(s); + } catch { + throw new BadRequestException(`invalid amount: ${s}`); + } +} diff --git a/typescript/tests/optimalPortfolioScenario.test.ts b/typescript/tests/optimalPortfolioScenario.test.ts new file mode 100644 index 0000000..dcafa83 --- /dev/null +++ b/typescript/tests/optimalPortfolioScenario.test.ts @@ -0,0 +1,40 @@ +import Decimal from "decimal.js"; +import { describe, it, expect } from "vitest"; +import { DummyServer } from "../src/infrastructure/server/dummyServer.js"; + +describe("Optimal Portfolio Management", () => { + it("最適ポートフォリオを更新・取得できる", async () => { + const server = DummyServer.default(); + const pc = server.portfolioController; + + // Given: 最適ポートフォリオを Toyopa=0.20, Somy=0.80 に更新する + await pc.updateOptimalPortfolio({ + portfolios: [ + { symbol: "Toyopa", rate: "0.20" }, + { symbol: "Somy", rate: "0.80" }, + ], + }); + + // When: 最適ポートフォリオを取得する + const first = await pc.getOptimalPortfolio(); + const firstMap = new Map(first.portfolios.map((p) => [p.symbol, p.rate])); + + // Then: Toyopa=0.20, Somy=0.80 が返される + expect(new Decimal(firstMap.get("Toyopa")!).equals(new Decimal("0.20"))).toBe(true); + expect(new Decimal(firstMap.get("Somy")!).equals(new Decimal("0.80"))).toBe(true); + + // When: 最適ポートフォリオを Toyopa=0.40, Somy=0.60 に更新して再取得する + await pc.updateOptimalPortfolio({ + portfolios: [ + { symbol: "Toyopa", rate: "0.40" }, + { symbol: "Somy", rate: "0.60" }, + ], + }); + const second = await pc.getOptimalPortfolio(); + const secondMap = new Map(second.portfolios.map((p) => [p.symbol, p.rate])); + + // Then: Toyopa=0.40, Somy=0.60 が返される + expect(new Decimal(secondMap.get("Toyopa")!).equals(new Decimal("0.40"))).toBe(true); + expect(new Decimal(secondMap.get("Somy")!).equals(new Decimal("0.60"))).toBe(true); + }); +}); diff --git a/typescript/tests/orderScenario.test.ts b/typescript/tests/orderScenario.test.ts new file mode 100644 index 0000000..c78c11b --- /dev/null +++ b/typescript/tests/orderScenario.test.ts @@ -0,0 +1,114 @@ +import Decimal from "decimal.js"; +import { randomUUID } from "node:crypto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { DummyServer } from "../src/infrastructure/server/dummyServer.js"; +import { BadRequestException } from "../src/presentation/presentationException.js"; + +describe("Investment Operation", () => { + let server: DummyServer; + + beforeEach(async () => { + server = DummyServer.default(); + await server.portfolioController.updateOptimalPortfolio({ + portfolios: [ + { symbol: "Toyopa", rate: "0.40" }, + { symbol: "Somy", rate: "0.60" }, + ], + }); + await server.marketPriceController.updateMarketPrice({ + market_prices: [ + { symbol: "Toyopa", market_price: "2.5" }, + { symbol: "Somy", market_price: "3.0" }, + ], + }); + }); + + it("新規拠出・追加拠出・リバランスの一連の操作が正しく機能する", async () => { + const ac = server.assetController; + const pc = server.portfolioController; + const oc = server.orderController; + + const userId = randomUUID(); + + // Given: 存在しないユーザーで資産を取得しようとする + let notFound: unknown; + try { + await ac.getAsset({ userId }); + } catch (e) { + notFound = e; + } + // Then: BadRequestException が返される + expect(notFound instanceof BadRequestException).toBe(true); + + // When: 最適ポートフォリオを Toyopa=40%, Somy=60% に更新する + await pc.updateOptimalPortfolio({ + portfolios: [ + { symbol: "Toyopa", rate: "0.40" }, + { symbol: "Somy", rate: "0.60" }, + ], + }); + + // And: 新規拠出を 100,000 円で注文する + await oc.newContributionOrder({ userId, amount: "100000" }); + + const asset1 = await ac.getAsset({ userId }); + expect(new Set(asset1.stocks.map((e) => e.symbol))).toEqual(new Set(["Toyopa", "Somy"])); + const total1 = asset1.stocks + .map((e) => new Decimal(e.evaluationAmount)) + .reduce((acc, v) => acc.plus(v), new Decimal(0)) + .plus(new Decimal(asset1.cashAmount)); + expect(total1.minus(100000).abs().lessThanOrEqualTo(2)).toBe(true); + + // Then: 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる + // investable = 100000 - floor0(100000 * 0.05) = 95000 + const asset1Toyopa = asset1.stocks.find((e) => e.symbol === "Toyopa")!; + const asset1Somy = asset1.stocks.find((e) => e.symbol === "Somy")!; + expect(new Decimal(asset1Toyopa.evaluationAmount).equals("38000")).toBe(true); // floor2(95000 * 0.40 / 2.5) = 15200株 * 2.5 + expect(new Decimal(asset1Somy.evaluationAmount).equals("57000")).toBe(true); // floor2(95000 * 0.60 / 3.0) = 19000株 * 3.0 + expect(new Decimal(asset1.cashAmount).equals("5000")).toBe(true); // 100000 - 38000 - 57000 + + // When: 追加拠出を 100,000 円で注文する + await oc.additionalContributionOrder({ userId, amount: "100000" }); + + // Then: 資産合計が約 200000 円になる + const asset2 = await ac.getAsset({ userId }); + const total2 = asset2.stocks + .map((e) => new Decimal(e.evaluationAmount)) + .reduce((acc, v) => acc.plus(v), new Decimal(0)) + .plus(new Decimal(asset2.cashAmount)); + expect(total2.minus(200000).abs().lessThanOrEqualTo(4)).toBe(true); + + // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる + // totalAfter = 200000; investable = 200000 - floor0(200000 * 0.05) = 190000 + const asset2Toyopa = asset2.stocks.find((e) => e.symbol === "Toyopa")!; + const asset2Somy = asset2.stocks.find((e) => e.symbol === "Somy")!; + expect(new Decimal(asset2Toyopa.evaluationAmount).equals("76000")).toBe(true); // floor2(190000 * 0.40 / 2.5) = 30400株 * 2.5 + expect(new Decimal(asset2Somy.evaluationAmount).equals("114000")).toBe(true); // floor2(190000 * 0.60 / 3.0) = 38000株 * 3.0 + expect(new Decimal(asset2.cashAmount).equals("10000")).toBe(true); // 200000 - 76000 - 114000 + + // When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする + await pc.updateOptimalPortfolio({ + portfolios: [ + { symbol: "Toyopa", rate: "0.10" }, + { symbol: "Somy", rate: "0.90" }, + ], + }); + await oc.rebalanceOrder({ userId }); + + // Then: リバランス後も資産合計がほぼ変わらない + const asset3 = await ac.getAsset({ userId }); + const total3 = asset3.stocks + .map((e) => new Decimal(e.evaluationAmount)) + .reduce((acc, v) => acc.plus(v), new Decimal(0)) + .plus(new Decimal(asset3.cashAmount)); + expect(total3.minus(total2).abs().lessThanOrEqualTo(4)).toBe(true); + + // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる + // total = 200000; investable = 200000 - floor0(200000 * 0.05) = 190000 + const asset3Toyopa = asset3.stocks.find((e) => e.symbol === "Toyopa")!; + const asset3Somy = asset3.stocks.find((e) => e.symbol === "Somy")!; + expect(new Decimal(asset3Toyopa.evaluationAmount).equals("19000")).toBe(true); // floor2(190000 * 0.10 / 2.5) = 7600株 * 2.5 + expect(new Decimal(asset3Somy.evaluationAmount).equals("171000")).toBe(true); // floor2(190000 * 0.90 / 3.0) = 57000株 * 3.0 + expect(new Decimal(asset3.cashAmount).equals("10000")).toBe(true); // 200000 - 19000 - 171000 + }); +}); diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json new file mode 100644 index 0000000..0f36fa8 --- /dev/null +++ b/typescript/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "outDir": "dist", + "rootDir": "." + }, + "include": ["src/**/*", "tests/**/*"] +}