diff --git a/.github/scripts/expect-order-scenario-failure.sh b/.github/scripts/expect-order-scenario-failure.sh new file mode 100755 index 0000000..43ece21 --- /dev/null +++ b/.github/scripts/expect-order-scenario-failure.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# order scenario テストは「リバランス後の Toyopa 保有額が 19,000 円ではなく +# 20,000 円になる」という既知の不具合を再現したまま残してある。 +# このスクリプトは CI で以下を確認する。 +# +# 1. order scenario テストが失敗すること(通ってしまったら CI を失敗させる) +# 2. 期待した箇所(リバランス後の保有額の検証)で失敗していること +# +# Usage: +# EXPECTED_FAILURE="" expect-order-scenario-failure.sh [args...] +# +# EXPECTED_FAILURE は改行区切りで複数指定でき、そのすべてが出力に含まれている +# 必要がある。 +set -uo pipefail + +if [ $# -eq 0 ]; then + echo "usage: EXPECTED_FAILURE= $0 [args...]" >&2 + exit 2 +fi + +output_file="$(mktemp)" +trap 'rm -f "${output_file}"' EXIT + +echo "::group::order scenario test output (failing on purpose)" +"$@" 2>&1 | tee "${output_file}" +status="${PIPESTATUS[0]}" +echo "::endgroup::" + +if [ "${status}" -eq 0 ]; then + echo "::error::order scenario test passed unexpectedly. This test is expected to fail at the rebalance assertion (19000 != 20000)." + exit 1 +fi + +while IFS= read -r expected; do + if [ -z "${expected}" ]; then + continue + fi + if ! grep -qF -- "${expected}" "${output_file}"; then + echo "::error::order scenario test failed, but not at the expected assertion. Missing message: ${expected}" + exit 1 + fi +done <<< "${EXPECTED_FAILURE}" + +echo "OK: order scenario test failed at the expected assertion (exit status ${status})." diff --git a/.github/workflows/test-go.yml b/.github/workflows/test-go.yml index 0a8f867..4bd976a 100644 --- a/.github/workflows/test-go.yml +++ b/.github/workflows/test-go.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/test-go.yml' - 'golang/**.go' - 'golang/go.mod' + - '.github/scripts/expect-order-scenario-failure.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -37,3 +38,9 @@ jobs: - run: go fmt ./... - run: go vet ./... - run: go test ./test/... -run TestOptimalPortfolioScenario + - name: Order scenario test should fail at the rebalance assertion + run: | + "${GITHUB_WORKSPACE}/.github/scripts/expect-order-scenario-failure.sh" \ + go test ./test/... -run TestOrderScenario + env: + EXPECTED_FAILURE: 'asset3 Toyopa amount: got 20000, want 19000' diff --git a/.github/workflows/test-java17.yml b/.github/workflows/test-java17.yml index 2ec39e8..d631c47 100644 --- a/.github/workflows/test-java17.yml +++ b/.github/workflows/test-java17.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/test-java17.yml' - 'java17/src/**' - 'java17/pom.xml' + - '.github/scripts/expect-order-scenario-failure.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -37,3 +38,9 @@ jobs: java-version: ${{ matrix.java-version }} cache: maven - run: ./mvnw test -q -Dtest=OptimalPortfolioScenarioTest + - name: Order scenario test should fail at the rebalance assertion + run: | + "${GITHUB_WORKSPACE}/.github/scripts/expect-order-scenario-failure.sh" \ + ./mvnw test -q -Dtest=OrderScenarioTest + env: + EXPECTED_FAILURE: 'expected 19000 but was 20000' diff --git a/.github/workflows/test-java8.yml b/.github/workflows/test-java8.yml index 6ae6e82..f944011 100644 --- a/.github/workflows/test-java8.yml +++ b/.github/workflows/test-java8.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/test-java8.yml' - 'java8/src/**' - 'java8/pom.xml' + - '.github/scripts/expect-order-scenario-failure.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -34,3 +35,9 @@ jobs: java-version: '8' cache: maven - run: ./mvnw test -q -Dtest=OptimalPortfolioScenarioTest + - name: Order scenario test should fail at the rebalance assertion + run: | + "${GITHUB_WORKSPACE}/.github/scripts/expect-order-scenario-failure.sh" \ + ./mvnw test -q -Dtest=OrderScenarioTest + env: + EXPECTED_FAILURE: 'expected 19000 but was 20000' diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 1595187..6bb8a41 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/test-php.yml' - 'php/src/**' - 'php/composer.json' + - '.github/scripts/expect-order-scenario-failure.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -34,3 +35,12 @@ jobs: - run: composer install --no-interaction --prefer-dist - run: find src -name '*.php' -exec php -l {} + - run: ./vendor/bin/phpunit tests/OptimalPortfolioScenarioTest.php + - name: Order scenario test should fail at the rebalance assertion + run: | + "${GITHUB_WORKSPACE}/.github/scripts/expect-order-scenario-failure.sh" \ + ./vendor/bin/phpunit tests/OrderScenarioTest.php + env: + EXPECTED_FAILURE: | + Failed asserting that two strings are identical. + '19000' + '20000' diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 12ec909..5e6a0e5 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/test-python.yml' - 'python/src/**' - 'python/pyproject.toml' + - '.github/scripts/expect-order-scenario-failure.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -35,3 +36,9 @@ jobs: - run: pip install ".[dev]" - run: find src -name '*.py' -exec python -m py_compile {} + - run: python -m pytest tests/test_optimal_portfolio_scenario.py + - name: Order scenario test should fail at the rebalance assertion + run: | + "${GITHUB_WORKSPACE}/.github/scripts/expect-order-scenario-failure.sh" \ + python -m pytest tests/test_order_scenario.py + env: + EXPECTED_FAILURE: "assert Decimal('20000') == Decimal('19000')" diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 10f46f2..de00e2e 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -8,6 +8,7 @@ on: - 'ruby/spec/**' - 'ruby/Gemfile' - 'ruby/Gemfile.lock' + - '.github/scripts/expect-order-scenario-failure.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -40,3 +41,9 @@ jobs: working-directory: ruby - run: find lib -name '*.rb' -exec ruby -c {} + - run: bundle exec rspec spec/optimal_portfolio_scenario_spec.rb + - name: Order scenario test should fail at the rebalance assertion + run: | + "${GITHUB_WORKSPACE}/.github/scripts/expect-order-scenario-failure.sh" \ + bundle exec rspec spec/order_scenario_spec.rb + env: + EXPECTED_FAILURE: 'expect(BigDecimal(asset3_toyopa.amount_jpy)).to eq(BigDecimal("19000"))' diff --git a/.github/workflows/test-scala.yml b/.github/workflows/test-scala.yml index 85ba58b..9fce94a 100644 --- a/.github/workflows/test-scala.yml +++ b/.github/workflows/test-scala.yml @@ -7,6 +7,7 @@ on: - 'scala/src/**' - 'scala/build.sbt' - 'scala/project/**' + - '.github/scripts/expect-order-scenario-failure.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -40,3 +41,9 @@ jobs: - uses: sbt/setup-sbt@af116cce31c00823d3903ce687f9cda3a4f19f1b # v1 - run: sbt compile - run: sbt "testOnly folio.codinginterview.OptimalPortfolioScenario" + - name: Order scenario test should fail at the rebalance assertion + run: | + "${GITHUB_WORKSPACE}/.github/scripts/expect-order-scenario-failure.sh" \ + sbt "testOnly folio.codinginterview.OrderScenario" + env: + EXPECTED_FAILURE: 'Expected 19000, but got 20000' diff --git a/.github/workflows/test-typescript.yml b/.github/workflows/test-typescript.yml index e4d8ff8..5df5fb6 100644 --- a/.github/workflows/test-typescript.yml +++ b/.github/workflows/test-typescript.yml @@ -6,7 +6,9 @@ on: - '.github/workflows/test-typescript.yml' - 'typescript/src/**' - 'typescript/package.json' + - 'typescript/tests/**' - 'typescript/tsconfig.json' + - '.github/scripts/expect-order-scenario-failure.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -37,3 +39,9 @@ jobs: - run: npm ci - run: npm run build - run: npx vitest run tests/optimalPortfolioScenario.test.ts + - name: Order scenario test should fail at the rebalance assertion + run: | + "${GITHUB_WORKSPACE}/.github/scripts/expect-order-scenario-failure.sh" \ + npx vitest run tests/orderScenario.test.ts + env: + EXPECTED_FAILURE: 'asset3 Toyopa amount: got 20000, want 19000' diff --git a/typescript/tests/orderScenario.test.ts b/typescript/tests/orderScenario.test.ts index ea81bb3..9dd223c 100644 --- a/typescript/tests/orderScenario.test.ts +++ b/typescript/tests/orderScenario.test.ts @@ -7,6 +7,19 @@ import { BadRequestException } from "../src/presentation/presentationException.j describe("Investment Operation", () => { let server: DummyServer; + // 失敗時に実測値がメッセージへ出るようにする(比較は Decimal 同士のまま) + const expectAmount = (label: string, actual: string, expected: string) => { + expect( + new Decimal(actual).equals(expected), + `${label}: got ${actual}, want ${expected}`, + ).toBe(true); + }; + + const totalOf = (asset: { cashAmount: string; stocks: { amountJpy: string }[] }) => + asset.stocks + .map((e) => new Decimal(e.amountJpy)) + .reduce((acc, v) => acc.plus(v), new Decimal(asset.cashAmount)); + beforeEach(async () => { server = DummyServer.default(); await server.portfolioController.updateOptimalPortfolio({ @@ -17,57 +30,60 @@ describe("Investment Operation", () => { }); }); - it("存在しないユーザーへのリクエストは BadRequestException を返す", async () => { + it("新規注文・追加注文・リバランスの一連の操作が正しく機能する", async () => { const userId = randomUUID(); + + // Given: 存在しないユーザーで資産を取得しようとする let notFound: unknown; try { await server.assetController.getAsset({ userId }); } catch (e) { notFound = e; } + // Then: BadRequestException が返される expect(notFound instanceof BadRequestException).toBe(true); - }); - it("asset1: 新規注文 100,000円 が正しく機能する", async () => { - const userId = randomUUID(); - // cash = floor0(100000 * 0.05) = 5000, investable = 100000 - 5000 = 95000 + // When: 最適ポートフォリオを Toyopa=40%, Somy=60% に更新する + await server.portfolioController.updateOptimalPortfolio({ + portfolios: [ + { symbol: "Toyopa", rate: "0.40" }, + { symbol: "Somy", rate: "0.60" }, + ], + }); + + // And: 新規注文を 100,000 円で注文する await server.orderController.newOrder({ userId, amount: "100000" }); + const asset1 = await server.assetController.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.amountJpy)) - .reduce((acc, v) => acc.plus(v), new Decimal(0)) - .plus(new Decimal(asset1.cashAmount)); + const total1 = totalOf(asset1); expect(total1.minus(100000).abs().lessThanOrEqualTo(2)).toBe(true); + + // Then: 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の保有額が 38,000 円(40%)、Somy の保有額が 57,000 円(60%) となる + // cash = floor0(100000 * 0.05) = 5000, investable = 100000 - 5000 = 95000 const asset1Toyopa = asset1.stocks.find((e) => e.symbol === "Toyopa")!; const asset1Somy = asset1.stocks.find((e) => e.symbol === "Somy")!; - expect(new Decimal(asset1Toyopa.amountJpy).equals("38000")).toBe(true); // floor0(95000 * 0.40) = 38000 - expect(new Decimal(asset1Somy.amountJpy).equals("57000")).toBe(true); // floor0(95000 * 0.60) = 57000 - expect(new Decimal(asset1.cashAmount).equals("5000")).toBe(true); // 100000 - 38000 - 57000 - }); + expectAmount("asset1 Toyopa amount", asset1Toyopa.amountJpy, "38000"); // floor0(95000 * 0.40) = 38000 + expectAmount("asset1 Somy amount", asset1Somy.amountJpy, "57000"); // floor0(95000 * 0.60) = 57000 + expectAmount("asset1 cash", asset1.cashAmount, "5000"); // 100000 - 38000 - 57000 - it("asset2: 追加注文 100,000円 が正しく機能する", async () => { - const userId = randomUUID(); - // totalAfter = 200000; investable = 200000 - floor0(200000 * 0.05) = 190000 - await server.orderController.newOrder({ userId, amount: "100000" }); + // When: 追加注文を 100,000 円で注文する await server.orderController.additionalOrder({ userId, amount: "100000" }); + + // Then: 資産合計が約 200,000 円になる const asset2 = await server.assetController.getAsset({ userId }); - const total2 = asset2.stocks - .map((e) => new Decimal(e.amountJpy)) - .reduce((acc, v) => acc.plus(v), new Decimal(0)) - .plus(new Decimal(asset2.cashAmount)); + const total2 = totalOf(asset2); 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.amountJpy).equals("76000")).toBe(true); // floor0(190000 * 0.40) = 76000 - expect(new Decimal(asset2Somy.amountJpy).equals("114000")).toBe(true); // floor0(190000 * 0.60) = 114000 - expect(new Decimal(asset2.cashAmount).equals("10000")).toBe(true); // 200000 - 76000 - 114000 - }); + expectAmount("asset2 Toyopa amount", asset2Toyopa.amountJpy, "76000"); // floor0(190000 * 0.40) = 76000 + expectAmount("asset2 Somy amount", asset2Somy.amountJpy, "114000"); // floor0(190000 * 0.60) = 114000 + expectAmount("asset2 cash", asset2.cashAmount, "10000"); // 200000 - 76000 - 114000 - it("asset3: リバランス注文が正しく機能する", async () => { - const userId = randomUUID(); - await server.orderController.newOrder({ userId, amount: "100000" }); - await server.orderController.additionalOrder({ userId, amount: "100000" }); + // When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする await server.portfolioController.updateOptimalPortfolio({ portfolios: [ { symbol: "Toyopa", rate: "0.10" }, @@ -75,12 +91,18 @@ describe("Investment Operation", () => { ], }); await server.orderController.rebalanceOrder({ userId }); - // total = 200000; investable = 200000 - floor0(200000 * 0.05) = 190000 + + // Then: リバランス後も資産合計がほぼ変わらない const asset3 = await server.assetController.getAsset({ userId }); + const total3 = totalOf(asset3); + 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.amountJpy).equals("19000")).toBe(true); // floor0(190000 * 0.10) = 19000 - expect(new Decimal(asset3Somy.amountJpy).equals("171000")).toBe(true); // floor0(190000 * 0.90) = 171000 - expect(new Decimal(asset3.cashAmount).equals("10000")).toBe(true); // 200000 - 19000 - 171000 + expectAmount("asset3 Toyopa amount", asset3Toyopa.amountJpy, "19000"); // floor0(190000 * 0.10) = 19000 + expectAmount("asset3 Somy amount", asset3Somy.amountJpy, "171000"); // floor0(190000 * 0.90) = 171000 + expectAmount("asset3 cash", asset3.cashAmount, "10000"); // 200000 - 19000 - 171000 }); });