Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/scripts/expect-order-scenario-failure.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# order scenario テストは「リバランス後の Toyopa 保有額が 19,000 円ではなく
# 20,000 円になる」という既知の不具合を再現したまま残してある。
# このスクリプトは CI で以下を確認する。
#
# 1. order scenario テストが失敗すること(通ってしまったら CI を失敗させる)
# 2. 期待した箇所(リバランス後の保有額の検証)で失敗していること
#
# Usage:
# EXPECTED_FAILURE="<substring>" expect-order-scenario-failure.sh <command> [args...]
#
# EXPECTED_FAILURE は改行区切りで複数指定でき、そのすべてが出力に含まれている
# 必要がある。
set -uo pipefail

if [ $# -eq 0 ]; then
echo "usage: EXPECTED_FAILURE=<substring> $0 <command> [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})."
7 changes: 7 additions & 0 deletions .github/workflows/test-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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'
7 changes: 7 additions & 0 deletions .github/workflows/test-java17.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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'
7 changes: 7 additions & 0 deletions .github/workflows/test-java8.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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'
10 changes: 10 additions & 0 deletions .github/workflows/test-php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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'
7 changes: 7 additions & 0 deletions .github/workflows/test-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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')"
7 changes: 7 additions & 0 deletions .github/workflows/test-ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"))'
7 changes: 7 additions & 0 deletions .github/workflows/test-scala.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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'
8 changes: 8 additions & 0 deletions .github/workflows/test-typescript.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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'
88 changes: 55 additions & 33 deletions typescript/tests/orderScenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@
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);

Check failure on line 15 in typescript/tests/orderScenario.test.ts

View workflow job for this annotation

GitHub Actions / Build

tests/orderScenario.test.ts > Investment Operation > 新規注文・追加注文・リバランスの一連の操作が正しく機能する

AssertionError: asset3 Toyopa amount: got 20000, want 19000: expected false to be true // Object.is equality - Expected + Received - true + false ❯ expectAmount tests/orderScenario.test.ts:15:7 ❯ tests/orderScenario.test.ts:104:5
};

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({
Expand All @@ -17,70 +30,79 @@
});
});

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" },
{ symbol: "Somy", rate: "0.90" },
],
});
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
});
});
Loading