From 7254985404845b69d7a69d423463426b8d4ecce1 Mon Sep 17 00:00:00 2001 From: krrrr38 Date: Sat, 20 Jun 2026 04:43:49 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor(golang):=20=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=81=AE=E5=89=8A=E9=99=A4=E3=81=A8=E3=83=89?= =?UTF-8?q?=E3=83=A1=E3=82=A4=E3=83=B3=E3=83=A2=E3=83=87=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=81=AB=E3=82=88=E3=82=8B=E7=B0=A1=E7=95=A5?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit market_price関連機能の削除、application/service層をドメインのAccountへ集約(OpenAccount/AddFunds/Rebalance/Total)、拠出表現を新規注文・追加注文へ統一、domain各typeへの説明コメント付与。 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: krrrr38 --- golang/README.md | 35 +++-- .../repository/market_price_repository.go | 13 -- .../application/service/asset_service.go | 29 ---- .../application/service/portfolio_service.go | 138 ------------------ .../usecase/asset/get_asset_usecase.go | 23 +-- .../update_market_price_usecase.go | 33 ----- .../order/additional_buy_order_usecase.go | 22 +-- .../order/new_contribution_order_usecase.go | 70 --------- .../usecase/order/new_order_usecase.go | 58 ++++++++ .../usecase/order/rebalance_order_usecase.go | 22 +-- golang/internal/domain/account.go | 105 +++++++++++++ golang/internal/domain/constants.go | 5 - golang/internal/domain/stock.go | 11 +- golang/internal/domain/stock_symbol.go | 1 + .../market_price_repository_impl.go | 40 ----- .../infrastructure/server/dummy_server.go | 30 ++-- .../internal/presentation/asset_controller.go | 8 +- .../presentation/market_price_controller.go | 44 ------ .../internal/presentation/order_controller.go | 20 +-- golang/test/order_scenario_test.go | 58 ++++---- 20 files changed, 252 insertions(+), 513 deletions(-) delete mode 100644 golang/internal/application/repository/market_price_repository.go delete mode 100644 golang/internal/application/service/asset_service.go delete mode 100644 golang/internal/application/service/portfolio_service.go delete mode 100644 golang/internal/application/usecase/market_price/update_market_price_usecase.go delete mode 100644 golang/internal/application/usecase/order/new_contribution_order_usecase.go create mode 100644 golang/internal/application/usecase/order/new_order_usecase.go create mode 100644 golang/internal/domain/account.go delete mode 100644 golang/internal/infrastructure/repository/market_price_repository_impl.go delete mode 100644 golang/internal/presentation/market_price_controller.go diff --git a/golang/README.md b/golang/README.md index d78bd6e..b077f7a 100644 --- a/golang/README.md +++ b/golang/README.md @@ -21,33 +21,32 @@ go test ./... Repository はモック実装としてin-memoryにデータを保持していますが、RDBを使う想定で回答してください。 -### 株と評価額 +### 銘柄と保有額 -- 株には株数(qty)があります(例: 1株、2株) -- 株には1株あたりの市場価格があります(例: 1株あたり100円) - - 例: 顧客が5株保有している場合、評価額はこの時点では `5株 × 100円 = 500円` となります +- 顧客は銘柄ごとに保有額(円)を保持します(例: A銘柄を 500 円分保有する) + - 簡略化のため、株数や市場価格は扱わず、各銘柄を金額(円)で直接保有するものとします ### ロボアドバイザーサービス - **顧客の口座** - - 新規拠出を行うと、口座がすぐに開きます + - 新規注文を行うと、口座がすぐに開きます - 口座の中で資産を管理することになります - **顧客の資産** - - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します - - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円分をいくつかの株で保有する - - 株は価格で保持するのではなく、株数で保持します - - そのため、市場価格に応じて評価額は変わることになります + - 顧客は現金と銘柄を保有し、総資産の5%は常に現金で保持します + - 例: 総資産105万円のうち5万円を現金として保持し、残り100万円分をいくつかの銘柄で保有する + - 各銘柄毎の資産は金額(円)で保持します - **最適ポートフォリオ** - - サービスが管理する、株の評価額ベースの構成比率 - - 例: A株を30%・B株を70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30%分の株数 + B株95万円*70%分の株数 になるように努める - - 購入時・売却時・リバランス時には、売買後の資産比率が __現在の最適ポートフォリオ__ に近づける形での売買を実施します -- **株の売買** - - 本アプリケーションでは、注文APIを叩くと __即時__ 株の売買が成立し資産に反映出来るものとします + - サービスが管理する、銘柄の保有額ベースの構成比率(現金は含めない) + - 例: A銘柄を30%・B銘柄を70%で保有する場合、総資産105万円のうち 5万円の現金 + A銘柄30万円分 + B銘柄70万円分 になるように努める + - 購入時・売却時・リバランス時には、注文後の資産比率が __現在の最適ポートフォリオ__ に近づける形での調整を実施します +- **資産の調整** + - 本アプリケーションでは、注文APIを叩くと __即時__ 売買が成立し資産に反映出来るものとします - 用語 - - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 - - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 - - 全売却注文: 運用中の株を全て売却すること。 - - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + - 新規注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加注文: 追加で資金を投入すること。この注文を入れると、運用する金額が増える。 + - 全売却注文: 運用中の銘柄を全て売却すること。 + - リバランス注文: 運用されている資産を、サービスで保有する最適ポートフォリオに近づけるよう調整すること。最適ポートフォリオの比率が変更された場合に、その比率へ寄せる。 + - 例: 最適ポートフォリオを `A銘柄30%+B銘柄70%` から `A銘柄50%+B銘柄50%` に変えてからリバランス注文をすると、顧客の口座も最新の最適ポートフォリオ通りの内容になる ## 確認観点 diff --git a/golang/internal/application/repository/market_price_repository.go b/golang/internal/application/repository/market_price_repository.go deleted file mode 100644 index d84f85c..0000000 --- a/golang/internal/application/repository/market_price_repository.go +++ /dev/null @@ -1,13 +0,0 @@ -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/service/asset_service.go b/golang/internal/application/service/asset_service.go deleted file mode 100644 index 79d93f6..0000000 --- a/golang/internal/application/service/asset_service.go +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index e012b90..0000000 --- a/golang/internal/application/service/portfolio_service.go +++ /dev/null @@ -1,138 +0,0 @@ -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 index 3262c9d..bdeb717 100644 --- a/golang/internal/application/usecase/asset/get_asset_usecase.go +++ b/golang/internal/application/usecase/asset/get_asset_usecase.go @@ -4,7 +4,6 @@ import ( "errors" "folio/codinginterview/internal/application/repository" - "folio/codinginterview/internal/application/service" "folio/codinginterview/internal/domain" "github.com/shopspring/decimal" @@ -13,8 +12,8 @@ import ( var ErrUserNotFound = errors.New("user not found") type GetAssetStockOutput struct { - Symbol domain.StockSymbol - EvaluationAmount decimal.Decimal + Symbol domain.StockSymbol + AmountJpy decimal.Decimal } type GetAssetUsecaseInput struct { @@ -27,12 +26,11 @@ type GetAssetUsecaseOutput struct { } type GetAssetUsecase struct { - accountRepo repository.AccountRepository - marketPriceRepo repository.MarketPriceRepository + accountRepo repository.AccountRepository } -func NewGetAssetUsecase(accountRepo repository.AccountRepository, marketPriceRepo repository.MarketPriceRepository) *GetAssetUsecase { - return &GetAssetUsecase{accountRepo: accountRepo, marketPriceRepo: marketPriceRepo} +func NewGetAssetUsecase(accountRepo repository.AccountRepository) *GetAssetUsecase { + return &GetAssetUsecase{accountRepo: accountRepo} } func (u *GetAssetUsecase) Run(input GetAssetUsecaseInput) (GetAssetUsecaseOutput, error) { @@ -44,18 +42,9 @@ func (u *GetAssetUsecase) Run(input GetAssetUsecaseInput) (GetAssetUsecaseOutput 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}) + stocks = append(stocks, GetAssetStockOutput{Symbol: s.Symbol, AmountJpy: s.AmountJpy}) } 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 deleted file mode 100644 index 85a6c18..0000000 --- a/golang/internal/application/usecase/market_price/update_market_price_usecase.go +++ /dev/null @@ -1,33 +0,0 @@ -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 index b7635e9..8b37c0c 100644 --- a/golang/internal/application/usecase/order/additional_buy_order_usecase.go +++ b/golang/internal/application/usecase/order/additional_buy_order_usecase.go @@ -4,7 +4,6 @@ import ( "errors" "folio/codinginterview/internal/application/repository" - "folio/codinginterview/internal/application/service" "folio/codinginterview/internal/domain" "github.com/shopspring/decimal" @@ -21,20 +20,17 @@ type AdditionalBuyOrderUsecaseInput struct { } type AdditionalBuyOrderUsecase struct { - accountRepo repository.AccountRepository - portfolioRepo repository.PortfolioRepository - marketPriceRepo repository.MarketPriceRepository + accountRepo repository.AccountRepository + portfolioRepo repository.PortfolioRepository } func NewAdditionalBuyOrderUsecase( accountRepo repository.AccountRepository, portfolioRepo repository.PortfolioRepository, - marketPriceRepo repository.MarketPriceRepository, ) *AdditionalBuyOrderUsecase { return &AdditionalBuyOrderUsecase{ - accountRepo: accountRepo, - portfolioRepo: portfolioRepo, - marketPriceRepo: marketPriceRepo, + accountRepo: accountRepo, + portfolioRepo: portfolioRepo, } } @@ -56,15 +52,7 @@ func (u *AdditionalBuyOrderUsecase) Run(input AdditionalBuyOrderUsecaseInput) er 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 - } + updated := account.AddFunds(input.Amount, portfolio) 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 deleted file mode 100644 index 4a60986..0000000 --- a/golang/internal/application/usecase/order/new_contribution_order_usecase.go +++ /dev/null @@ -1,70 +0,0 @@ -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/new_order_usecase.go b/golang/internal/application/usecase/order/new_order_usecase.go new file mode 100644 index 0000000..fe663be --- /dev/null +++ b/golang/internal/application/usecase/order/new_order_usecase.go @@ -0,0 +1,58 @@ +package order + +import ( + "errors" + + "folio/codinginterview/internal/application/repository" + "folio/codinginterview/internal/domain" + + "github.com/shopspring/decimal" +) + +var ( + ErrNewOrderUserAlreadyExists = errors.New("user already has account") + ErrNewOrderAmountTooSmall = errors.New("amount is too small") +) + +type NewOrderUsecaseInput struct { + UserId domain.UserId + Amount decimal.Decimal +} + +type NewOrderUsecase struct { + accountRepo repository.AccountRepository + portfolioRepo repository.PortfolioRepository +} + +func NewNewOrderUsecase( + accountRepo repository.AccountRepository, + portfolioRepo repository.PortfolioRepository, +) *NewOrderUsecase { + return &NewOrderUsecase{ + accountRepo: accountRepo, + portfolioRepo: portfolioRepo, + } +} + +func (u *NewOrderUsecase) Run(input NewOrderUsecaseInput) error { + if input.Amount.LessThan(domain.MinOperationAmount) { + return ErrNewOrderAmountTooSmall + } + + exists, err := u.accountRepo.Exists(input.UserId) + if err != nil { + return err + } + if exists { + return ErrNewOrderUserAlreadyExists + } + + portfolio, err := u.portfolioRepo.Get() + if err != nil { + return err + } + + account := domain.OpenAccount(input.Amount, portfolio) + + 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 index 39f2698..aaeaf87 100644 --- a/golang/internal/application/usecase/order/rebalance_order_usecase.go +++ b/golang/internal/application/usecase/order/rebalance_order_usecase.go @@ -4,7 +4,6 @@ import ( "errors" "folio/codinginterview/internal/application/repository" - "folio/codinginterview/internal/application/service" "folio/codinginterview/internal/domain" ) @@ -15,20 +14,17 @@ type RebalanceOrderUsecaseInput struct { } type RebalanceOrderUsecase struct { - accountRepo repository.AccountRepository - portfolioRepo repository.PortfolioRepository - marketPriceRepo repository.MarketPriceRepository + accountRepo repository.AccountRepository + portfolioRepo repository.PortfolioRepository } func NewRebalanceOrderUsecase( accountRepo repository.AccountRepository, portfolioRepo repository.PortfolioRepository, - marketPriceRepo repository.MarketPriceRepository, ) *RebalanceOrderUsecase { return &RebalanceOrderUsecase{ - accountRepo: accountRepo, - portfolioRepo: portfolioRepo, - marketPriceRepo: marketPriceRepo, + accountRepo: accountRepo, + portfolioRepo: portfolioRepo, } } @@ -46,15 +42,7 @@ func (u *RebalanceOrderUsecase) Run(input RebalanceOrderUsecaseInput) error { return err } - prices, err := u.marketPriceRepo.All() - if err != nil { - return err - } - - updated, err := service.Rebalance(*account, portfolio, prices) - if err != nil { - return err - } + updated := account.Rebalance(portfolio) return u.accountRepo.Upsert(input.UserId, updated) } diff --git a/golang/internal/domain/account.go b/golang/internal/domain/account.go new file mode 100644 index 0000000..04d4160 --- /dev/null +++ b/golang/internal/domain/account.go @@ -0,0 +1,105 @@ +package domain + +import "github.com/shopspring/decimal" + +// Account は口座を表す。 +type Account struct { + Cash decimal.Decimal + Stocks []Stock +} + +// floor0 は円未満を切り捨てる(資産配分はすべて円単位で行う)。 +func floor0(x decimal.Decimal) decimal.Decimal { + return x.Truncate(0) +} + +// Total は口座の総資産(現金 + 各銘柄の保有額)を返す。 +func (a Account) Total() decimal.Decimal { + total := a.Cash + for _, s := range a.Stocks { + total = total.Add(s.AmountJpy) + } + return total +} + +// OpenAccount は新規注文額を、最適ポートフォリオに沿って配分した口座を生成する。 +func OpenAccount(amount decimal.Decimal, portfolio Portfolio) Account { + cashFromRate := floor0(amount.Mul(CashRate)) + investable := amount.Sub(cashFromRate) + + stocks := make([]Stock, 0, len(portfolio.Items)) + usedForStocks := decimal.Zero + for _, item := range portfolio.Items { + amt := floor0(investable.Mul(item.Rate)) + stocks = append(stocks, Stock{Symbol: item.Symbol, AmountJpy: amt}) + usedForStocks = usedForStocks.Add(amt) + } + + residual := investable.Sub(usedForStocks) + + return Account{Cash: cashFromRate.Add(residual), Stocks: stocks} +} + +// AddFunds は追加注文額を口座へ反映する。最適ポートフォリオの目標額を下回らない範囲で +// 既存の保有額を維持し、ポートフォリオ外の銘柄はそのまま保持する。 +func (a Account) AddFunds(amount decimal.Decimal, portfolio Portfolio) Account { + totalAfter := a.Total().Add(amount) + targetCash := floor0(totalAfter.Mul(CashRate)) + investable := totalAfter.Sub(targetCash) + + currentAmount := make(map[StockSymbol]decimal.Decimal) + for _, s := range a.Stocks { + currentAmount[s.Symbol] = s.AmountJpy + } + + portfolioSymbols := make(map[StockSymbol]struct{}) + for _, item := range portfolio.Items { + portfolioSymbols[item.Symbol] = struct{}{} + } + + newPortfolioStocks := make([]Stock, 0, len(portfolio.Items)) + for _, item := range portfolio.Items { + target := floor0(investable.Mul(item.Rate)) + current := currentAmount[item.Symbol] + final := target + if current.GreaterThan(target) { + final = current + } + newPortfolioStocks = append(newPortfolioStocks, Stock{Symbol: item.Symbol, AmountJpy: final}) + } + + preservedStocks := make([]Stock, 0) + for _, s := range a.Stocks { + if _, inPortfolio := portfolioSymbols[s.Symbol]; !inPortfolio { + preservedStocks = append(preservedStocks, s) + } + } + + allStocks := append(newPortfolioStocks, preservedStocks...) + + finalAmount := decimal.Zero + for _, s := range allStocks { + finalAmount = finalAmount.Add(s.AmountJpy) + } + finalCash := totalAfter.Sub(finalAmount) + + return Account{Cash: finalCash, Stocks: allStocks} +} + +// Rebalance は保有資産を最適ポートフォリオの比率に近づける。 +func (a Account) Rebalance(portfolio Portfolio) Account { + // XXX this implementation might not be correct + investable := a.Total() + + newStocks := make([]Stock, 0, len(portfolio.Items)) + usedForStocks := decimal.Zero + for _, item := range portfolio.Items { + amt := floor0(investable.Mul(item.Rate)) + newStocks = append(newStocks, Stock{Symbol: item.Symbol, AmountJpy: amt}) + usedForStocks = usedForStocks.Add(amt) + } + + finalCash := investable.Sub(usedForStocks) + + return Account{Cash: finalCash, Stocks: newStocks} +} diff --git a/golang/internal/domain/constants.go b/golang/internal/domain/constants.go index 59c28ad..61dfac5 100644 --- a/golang/internal/domain/constants.go +++ b/golang/internal/domain/constants.go @@ -6,11 +6,6 @@ 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 { diff --git a/golang/internal/domain/stock.go b/golang/internal/domain/stock.go index 1faddba..e9c5379 100644 --- a/golang/internal/domain/stock.go +++ b/golang/internal/domain/stock.go @@ -7,9 +7,10 @@ import ( "github.com/shopspring/decimal" ) +// Stock は保有銘柄(銘柄と保有額)を表す。 type Stock struct { - Symbol StockSymbol - Qty decimal.Decimal + Symbol StockSymbol + AmountJpy decimal.Decimal } type PortfolioItem struct { @@ -17,6 +18,7 @@ type PortfolioItem struct { Rate decimal.Decimal } +// Portfolio は最適ポートフォリオ(銘柄ごとの構成比率)を表す。 type Portfolio struct { Items []PortfolioItem } @@ -41,8 +43,3 @@ func NewPortfolio(items []PortfolioItem) (Portfolio, error) { } 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 index e8e36eb..ee76fd6 100644 --- a/golang/internal/domain/stock_symbol.go +++ b/golang/internal/domain/stock_symbol.go @@ -2,6 +2,7 @@ package domain import "fmt" +// StockSymbol は銘柄を表す。 type StockSymbol string const ( diff --git a/golang/internal/infrastructure/repository/market_price_repository_impl.go b/golang/internal/infrastructure/repository/market_price_repository_impl.go deleted file mode 100644 index f2d66e2..0000000 --- a/golang/internal/infrastructure/repository/market_price_repository_impl.go +++ /dev/null @@ -1,40 +0,0 @@ -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/server/dummy_server.go b/golang/internal/infrastructure/server/dummy_server.go index 9a59d5f..c62c3b0 100644 --- a/golang/internal/infrastructure/server/dummy_server.go +++ b/golang/internal/infrastructure/server/dummy_server.go @@ -1,43 +1,37 @@ package server import ( - marketprice "folio/codinginterview/internal/application/usecase/market_price" + assetusecase "folio/codinginterview/internal/application/usecase/asset" "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 + AssetController *presentation.AssetController + PortfolioController *presentation.PortfolioController + OrderController *presentation.OrderController } func NewDefaultDummyServer() *DummyServer { portfolioRepo := infrarepo.NewPortfolioRepositoryImpl() accountRepo := infrarepo.NewAccountRepositoryImpl() - marketPriceRepo := infrarepo.NewMarketPriceRepositoryImpl() - getAssetUsecase := assetusecase.NewGetAssetUsecase(accountRepo, marketPriceRepo) + getAssetUsecase := assetusecase.NewGetAssetUsecase(accountRepo) 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) + newOrderUsecase := order.NewNewOrderUsecase(accountRepo, portfolioRepo) + additionalBuyOrderUsecase := order.NewAdditionalBuyOrderUsecase(accountRepo, portfolioRepo) + rebalanceOrderUsecase := order.NewRebalanceOrderUsecase(accountRepo, portfolioRepo) assetController := presentation.NewAssetController(getAssetUsecase) portfolioController := presentation.NewPortfolioController(getLatestPortfolioUsecase, updatePortfolioUsecase) - orderController := presentation.NewOrderController(newContributionOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase) - marketPriceController := presentation.NewMarketPriceController(updateMarketPriceUsecase) + orderController := presentation.NewOrderController(newOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase) return &DummyServer{ - AssetController: assetController, - PortfolioController: portfolioController, - OrderController: orderController, - MarketPriceController: marketPriceController, + AssetController: assetController, + PortfolioController: portfolioController, + OrderController: orderController, } } diff --git a/golang/internal/presentation/asset_controller.go b/golang/internal/presentation/asset_controller.go index a9967ee..22036ee 100644 --- a/golang/internal/presentation/asset_controller.go +++ b/golang/internal/presentation/asset_controller.go @@ -7,8 +7,8 @@ import ( ) type StockDto struct { - Symbol string - EvaluationAmount string + Symbol string + AmountJpy string } type GetAssetRequest struct { @@ -45,8 +45,8 @@ func (c *AssetController) GetAsset(req GetAssetRequest) (GetAssetResponse, error stocks := make([]StockDto, 0, len(out.Stocks)) for _, s := range out.Stocks { stocks = append(stocks, StockDto{ - Symbol: string(s.Symbol), - EvaluationAmount: s.EvaluationAmount.String(), + Symbol: string(s.Symbol), + AmountJpy: s.AmountJpy.String(), }) } return GetAssetResponse{ diff --git a/golang/internal/presentation/market_price_controller.go b/golang/internal/presentation/market_price_controller.go deleted file mode 100644 index 95cdd84..0000000 --- a/golang/internal/presentation/market_price_controller.go +++ /dev/null @@ -1,44 +0,0 @@ -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 index 49a1306..9d332e9 100644 --- a/golang/internal/presentation/order_controller.go +++ b/golang/internal/presentation/order_controller.go @@ -6,12 +6,12 @@ import ( "folio/codinginterview/internal/application/usecase/order" ) -type NewContributionOrderRequest struct { +type NewOrderRequest struct { UserId string Amount string } -type AdditionalContributionOrderRequest struct { +type AdditionalOrderRequest struct { UserId string Amount string } @@ -21,24 +21,24 @@ type RebalanceOrderRequest struct { } type OrderController struct { - newContributionOrderUsecase *order.NewContributionOrderUsecase + newOrderUsecase *order.NewOrderUsecase additionalBuyOrderUsecase *order.AdditionalBuyOrderUsecase rebalanceOrderUsecase *order.RebalanceOrderUsecase } func NewOrderController( - newContributionOrderUsecase *order.NewContributionOrderUsecase, + newOrderUsecase *order.NewOrderUsecase, additionalBuyOrderUsecase *order.AdditionalBuyOrderUsecase, rebalanceOrderUsecase *order.RebalanceOrderUsecase, ) *OrderController { return &OrderController{ - newContributionOrderUsecase: newContributionOrderUsecase, + newOrderUsecase: newOrderUsecase, additionalBuyOrderUsecase: additionalBuyOrderUsecase, rebalanceOrderUsecase: rebalanceOrderUsecase, } } -func (c *OrderController) NewContributionOrder(req NewContributionOrderRequest) error { +func (c *OrderController) NewOrder(req NewOrderRequest) error { uid, err := parseUserId(req.UserId) if err != nil { return err @@ -48,12 +48,12 @@ func (c *OrderController) NewContributionOrder(req NewContributionOrderRequest) return err } - err = c.newContributionOrderUsecase.Run(order.NewContributionOrderUsecaseInput{UserId: uid, Amount: amt}) + err = c.newOrderUsecase.Run(order.NewOrderUsecaseInput{UserId: uid, Amount: amt}) if err != nil { - if errors.Is(err, order.ErrNewContributionUserAlreadyExists) { + if errors.Is(err, order.ErrNewOrderUserAlreadyExists) { return newBadRequest("user already has account") } - if errors.Is(err, order.ErrNewContributionAmountTooSmall) { + if errors.Is(err, order.ErrNewOrderAmountTooSmall) { return newBadRequest("amount is too small") } return err @@ -61,7 +61,7 @@ func (c *OrderController) NewContributionOrder(req NewContributionOrderRequest) return nil } -func (c *OrderController) AdditionalContributionOrder(req AdditionalContributionOrderRequest) error { +func (c *OrderController) AdditionalOrder(req AdditionalOrderRequest) error { uid, err := parseUserId(req.UserId) if err != nil { return err diff --git a/golang/test/order_scenario_test.go b/golang/test/order_scenario_test.go index f1afb07..de74563 100644 --- a/golang/test/order_scenario_test.go +++ b/golang/test/order_scenario_test.go @@ -15,9 +15,8 @@ func TestOrderScenario(t *testing.T) { ac := s.AssetController pc := s.PortfolioController oc := s.OrderController - mp := s.MarketPriceController - // initialize market price and optimal portfolio + // initialize optimal portfolio err := pc.UpdateOptimalPortfolio(presentation.UpdateOptimalPortfolioRequest{ Portfolios: []presentation.PortfolioItemDto{ {Symbol: "Toyopa", Rate: "0.40"}, @@ -27,15 +26,6 @@ func TestOrderScenario(t *testing.T) { 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) @@ -57,14 +47,14 @@ func TestOrderScenario(t *testing.T) { t.Fatalf("UpdateOptimalPortfolio failed: %v", err) } - t.Log("新規拠出を 100,000 円で注文する") - err = oc.NewContributionOrder(presentation.NewContributionOrderRequest{UserId: userId, Amount: "100000"}) + t.Log("新規注文を 100,000 円で行う") + err = oc.NewOrder(presentation.NewOrderRequest{UserId: userId, Amount: "100000"}) if err != nil { - t.Fatalf("NewContributionOrder failed: %v", err) + t.Fatalf("NewOrder failed: %v", err) } - t.Log("現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる") - // investable = 100000 - floor0(100000 * 0.05) = 95000 + t.Log("現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の保有額が 38,000 円(40%)、Somy の保有額が 57,000 円(60%) となる") + // cash = floor0(100000 * 0.05) = 5000, investable = 100000 - 5000 = 95000 asset1, err := ac.GetAsset(presentation.GetAssetRequest{UserId: userId}) if err != nil { t.Fatalf("GetAsset failed: %v", err) @@ -81,7 +71,7 @@ func TestOrderScenario(t *testing.T) { } total1 := mustDecimal(asset1.CashAmount) for _, s := range asset1.Stocks { - total1 = total1.Add(mustDecimal(s.EvaluationAmount)) + total1 = total1.Add(mustDecimal(s.AmountJpy)) } diff1 := total1.Sub(decimal.NewFromInt(100000)).Abs() if diff1.GreaterThan(decimal.NewFromInt(2)) { @@ -90,14 +80,14 @@ func TestOrderScenario(t *testing.T) { 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 + assertDecimalEqual(t, "asset1 Toyopa amount", asset1Toyopa.AmountJpy, "38000") // floor0(95000 * 0.40) = 38000 + assertDecimalEqual(t, "asset1 Somy amount", asset1Somy.AmountJpy, "57000") // floor0(95000 * 0.60) = 57000 + assertDecimalEqual(t, "asset1 cash", asset1.CashAmount, "5000") // 100000 - 38000 - 57000 - t.Log("追加拠出を 100,000 円で注文する") - err = oc.AdditionalContributionOrder(presentation.AdditionalContributionOrderRequest{UserId: userId, Amount: "100000"}) + t.Log("追加注文を 100,000 円で行う") + err = oc.AdditionalOrder(presentation.AdditionalOrderRequest{UserId: userId, Amount: "100000"}) if err != nil { - t.Fatalf("AdditionalContributionOrder failed: %v", err) + t.Fatalf("AdditionalOrder failed: %v", err) } t.Log("資産合計が約 200,000 円になる") @@ -107,19 +97,20 @@ func TestOrderScenario(t *testing.T) { } total2 := mustDecimal(asset2.CashAmount) for _, s := range asset2.Stocks { - total2 = total2.Add(mustDecimal(s.EvaluationAmount)) + total2 = total2.Add(mustDecimal(s.AmountJpy)) } 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%) となる") + 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 + // totalAfter = 100000 + 100000 = 200000, cash = floor0(200000 * 0.05) = 10000, investable = 190000 + assertDecimalEqual(t, "asset2 Toyopa amount", asset2Toyopa.AmountJpy, "76000") // floor0(190000 * 0.40) = 76000 + assertDecimalEqual(t, "asset2 Somy amount", asset2Somy.AmountJpy, "114000") // floor0(190000 * 0.60) = 114000 + assertDecimalEqual(t, "asset2 cash", asset2.CashAmount, "10000") // 200000 - 76000 - 114000 t.Log("最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする") err = pc.UpdateOptimalPortfolio(presentation.UpdateOptimalPortfolioRequest{ @@ -143,19 +134,20 @@ func TestOrderScenario(t *testing.T) { } total3 := mustDecimal(asset3.CashAmount) for _, s := range asset3.Stocks { - total3 = total3.Add(mustDecimal(s.EvaluationAmount)) + total3 = total3.Add(mustDecimal(s.AmountJpy)) } 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%) となる") + 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 + // total = 200000, cash = floor0(200000 * 0.05) = 10000, investable = 190000 + assertDecimalEqual(t, "asset3 Toyopa amount", asset3Toyopa.AmountJpy, "19000") // floor0(190000 * 0.10) = 19000 + assertDecimalEqual(t, "asset3 Somy amount", asset3Somy.AmountJpy, "171000") // floor0(190000 * 0.90) = 171000 + assertDecimalEqual(t, "asset3 cash", asset3.CashAmount, "10000") // 200000 - 19000 - 171000 } func findStock(stocks []presentation.StockDto, symbol string) presentation.StockDto { From fd9294a257383a604dbc274866ea24dc7e2ccdfa Mon Sep 17 00:00:00 2001 From: krrrr38 Date: Sat, 20 Jun 2026 05:31:40 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor(java8):=20=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=81=AE=E5=89=8A=E9=99=A4=E3=81=A8=E3=83=89?= =?UTF-8?q?=E3=83=A1=E3=82=A4=E3=83=B3=E3=83=A2=E3=83=87=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=81=AB=E3=82=88=E3=82=8B=E7=B0=A1=E7=95=A5?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: krrrr38 --- java8/README.md | 35 +++-- .../repository/MarketPriceRepository.java | 16 --- .../application/service/AssetService.java | 28 ---- .../application/service/PortfolioService.java | 130 ------------------ .../usecase/asset/GetAssetUsecase.java | 26 ++-- .../UpdateMarketPriceUsecase.java | 47 ------- .../order/AdditionalBuyOrderUsecase.java | 19 +-- ...OrderUsecase.java => NewOrderUsecase.java} | 26 ++-- .../usecase/order/RebalanceOrderUsecase.java | 19 +-- .../folio/codinginterview/domain/Account.java | 99 +++++++++++++ .../codinginterview/domain/AppConstants.java | 11 -- .../codinginterview/domain/Portfolio.java | 1 + .../codinginterview/domain/PortfolioItem.java | 1 + .../folio/codinginterview/domain/Stock.java | 9 +- .../codinginterview/domain/StockSymbol.java | 1 + .../folio/codinginterview/domain/UserId.java | 1 + .../repository/MarketPriceRepositoryImpl.java | 28 ---- .../infrastructure/server/DummyServer.java | 31 ++--- .../presentation/AssetController.java | 10 +- .../presentation/MarketPriceController.java | 61 -------- .../presentation/OrderController.java | 26 ++-- .../codinginterview/OrderScenarioTest.java | 42 +++--- 22 files changed, 198 insertions(+), 469 deletions(-) delete mode 100644 java8/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java delete mode 100644 java8/src/main/java/folio/codinginterview/application/service/AssetService.java delete mode 100644 java8/src/main/java/folio/codinginterview/application/service/PortfolioService.java delete mode 100644 java8/src/main/java/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.java rename java8/src/main/java/folio/codinginterview/application/usecase/order/{NewContributionOrderUsecase.java => NewOrderUsecase.java} (70%) delete mode 100644 java8/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java delete mode 100644 java8/src/main/java/folio/codinginterview/presentation/MarketPriceController.java diff --git a/java8/README.md b/java8/README.md index b22c431..9901568 100644 --- a/java8/README.md +++ b/java8/README.md @@ -21,33 +21,32 @@ git init && git add . && git commit -m init Repository はモック実装としてin-memoryにデータを保持していますが、RDBを使う想定で回答してください。 -### 株と評価額 +### 銘柄と保有額 -- 株には株数(qty)があります(例: 1株、2株) -- 株には1株あたりの市場価格があります(例: 1株あたり100円) - - 例: 顧客が5株保有している場合、評価額はこの時点では `5株 × 100円 = 500円` となります +- 顧客は銘柄ごとに保有額(円)を保持します(例: A銘柄を 500 円分保有する) + - 簡略化のため、株数や市場価格は扱わず、各銘柄を金額(円)で直接保有するものとします ### ロボアドバイザーサービス - **顧客の口座** - - 新規拠出を行うと、口座がすぐに開きます + - 新規注文を行うと、口座がすぐに開きます - 口座の中で資産を管理することになります - **顧客の資産** - - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します - - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円分をいくつかの株で保有する - - 株は価格で保持するのではなく、株数で保持します - - そのため、市場価格に応じて評価額は変わることになります + - 顧客は現金と銘柄を保有し、総資産の5%は常に現金で保持します + - 例: 総資産105万円のうち5万円を現金として保持し、残り100万円分をいくつかの銘柄で保有する + - 各銘柄毎の資産は金額(円)で保持します - **最適ポートフォリオ** - - サービスが管理する、株の評価額ベースの構成比率 - - 例: A株を30%・B株を70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30%分の株数 + B株95万円*70%分の株数 になるように努める - - 購入時・売却時・リバランス時には、売買後の資産比率が __現在の最適ポートフォリオ__ に近づける形での売買を実施します -- **株の売買** - - 本アプリケーションでは、注文APIを叩くと __即時__ 株の売買が成立し資産に反映出来るものとします + - サービスが管理する、銘柄の保有額ベースの構成比率(現金は含めない) + - 例: A銘柄を30%・B銘柄を70%で保有する場合、総資産105万円のうち 5万円の現金 + A銘柄30万円分 + B銘柄70万円分 になるように努める + - 購入時・売却時・リバランス時には、注文後の資産比率が __現在の最適ポートフォリオ__ に近づける形での調整を実施します +- **資産の調整** + - 本アプリケーションでは、注文APIを叩くと __即時__ 売買が成立し資産に反映出来るものとします - 用語 - - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 - - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 - - 全売却注文: 運用中の株を全て売却すること。 - - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + - 新規注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加注文: 追加で資金を投入すること。この注文を入れると、運用する金額が増える。 + - 全売却注文: 運用中の銘柄を全て売却すること。 + - リバランス注文: 運用されている資産を、サービスで保有する最適ポートフォリオに近づけるよう調整すること。最適ポートフォリオの比率が変更された場合に、その比率へ寄せる。 + - 例: 最適ポートフォリオを `A銘柄30%+B銘柄70%` から `A銘柄50%+B銘柄50%` に変えてからリバランス注文をすると、顧客の口座も最新の最適ポートフォリオ通りの内容になる ## 確認観点 diff --git a/java8/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java b/java8/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java deleted file mode 100644 index 499c187..0000000 --- a/java8/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -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/service/AssetService.java b/java8/src/main/java/folio/codinginterview/application/service/AssetService.java deleted file mode 100644 index 89b0f32..0000000 --- a/java8/src/main/java/folio/codinginterview/application/service/AssetService.java +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 474f0c5..0000000 --- a/java8/src/main/java/folio/codinginterview/application/service/PortfolioService.java +++ /dev/null @@ -1,130 +0,0 @@ -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 index 4d14249..7be2033 100644 --- a/java8/src/main/java/folio/codinginterview/application/usecase/asset/GetAssetUsecase.java +++ b/java8/src/main/java/folio/codinginterview/application/usecase/asset/GetAssetUsecase.java @@ -1,8 +1,6 @@ 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; @@ -24,13 +22,13 @@ public static final class Input { public static final class StockOutput { private final StockSymbol symbol; - private final BigDecimal evaluationAmount; - public StockOutput(StockSymbol symbol, BigDecimal evaluationAmount) { + private final BigDecimal amountJpy; + public StockOutput(StockSymbol symbol, BigDecimal amountJpy) { this.symbol = symbol; - this.evaluationAmount = evaluationAmount; + this.amountJpy = amountJpy; } public StockSymbol symbol() { return symbol; } - public BigDecimal evaluationAmount() { return evaluationAmount; } + public BigDecimal amountJpy() { return amountJpy; } } public static final class Output { @@ -54,11 +52,9 @@ public static final class UserNotFound extends Exception { } private final AccountRepository accountRepository; - private final MarketPriceRepository marketPriceRepository; - public GetAssetUsecase(AccountRepository accountRepository, MarketPriceRepository marketPriceRepository) { + public GetAssetUsecase(AccountRepository accountRepository) { this.accountRepository = accountRepository; - this.marketPriceRepository = marketPriceRepository; } public CompletableFuture run(Input input) { @@ -69,13 +65,11 @@ public CompletableFuture run(Input input) { 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); - }); + List stocks = new ArrayList<>(); + for (Stock e : account.stocks()) { + stocks.add(new StockOutput(e.symbol(), e.amountJpy())); + } + return CompletableFuture.completedFuture(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 deleted file mode 100644 index 6cc6c99..0000000 --- a/java8/src/main/java/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.java +++ /dev/null @@ -1,47 +0,0 @@ -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 index b1de329..3b3ec9c 100644 --- a/java8/src/main/java/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.java +++ b/java8/src/main/java/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.java @@ -1,9 +1,7 @@ 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; @@ -40,16 +38,10 @@ public static final class AmountTooSmall extends Exception { private final AccountRepository accountRepository; private final PortfolioRepository portfolioRepository; - private final MarketPriceRepository marketPriceRepository; - public AdditionalBuyOrderUsecase( - AccountRepository accountRepository, - PortfolioRepository portfolioRepository, - MarketPriceRepository marketPriceRepository - ) { + public AdditionalBuyOrderUsecase(AccountRepository accountRepository, PortfolioRepository portfolioRepository) { this.accountRepository = accountRepository; this.portfolioRepository = portfolioRepository; - this.marketPriceRepository = marketPriceRepository; } public CompletableFuture run(Input input) { @@ -65,11 +57,10 @@ public CompletableFuture run(Input input) { 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); - })); + return portfolioRepository.get().thenCompose(portfolio -> { + Account updated = account.addFunds(input.amount(), portfolio); + 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/NewOrderUsecase.java similarity index 70% rename from java8/src/main/java/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.java rename to java8/src/main/java/folio/codinginterview/application/usecase/order/NewOrderUsecase.java index d7a5320..c627dea 100644 --- a/java8/src/main/java/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.java +++ b/java8/src/main/java/folio/codinginterview/application/usecase/order/NewOrderUsecase.java @@ -1,17 +1,16 @@ 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 NewContributionOrderUsecase { +public final class NewOrderUsecase { public static final class Input { private final UserId userId; private final BigDecimal amount; @@ -39,16 +38,10 @@ public static final class AmountTooSmall extends Exception { private final AccountRepository accountRepository; private final PortfolioRepository portfolioRepository; - private final MarketPriceRepository marketPriceRepository; - public NewContributionOrderUsecase( - AccountRepository accountRepository, - PortfolioRepository portfolioRepository, - MarketPriceRepository marketPriceRepository - ) { + public NewOrderUsecase(AccountRepository accountRepository, PortfolioRepository portfolioRepository) { this.accountRepository = accountRepository; this.portfolioRepository = portfolioRepository; - this.marketPriceRepository = marketPriceRepository; } public CompletableFuture run(Input input) { @@ -57,17 +50,16 @@ public CompletableFuture run(Input input) { failed.completeExceptionally(AmountTooSmall.INSTANCE); return failed; } - return accountRepository.exists(input.userId()).thenCompose(exists -> { - if (exists) { + return accountRepository.find(input.userId()).thenCompose((Optional maybeAccount) -> { + if (maybeAccount.isPresent()) { 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); - })); + return portfolioRepository.get().thenCompose(portfolio -> { + Account account = Account.openAccount(input.amount(), portfolio); + 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 index c13448b..dacbe6d 100644 --- a/java8/src/main/java/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.java +++ b/java8/src/main/java/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.java @@ -1,9 +1,7 @@ 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; @@ -28,16 +26,10 @@ public static final class UserNotFound extends Exception { private final AccountRepository accountRepository; private final PortfolioRepository portfolioRepository; - private final MarketPriceRepository marketPriceRepository; - public RebalanceOrderUsecase( - AccountRepository accountRepository, - PortfolioRepository portfolioRepository, - MarketPriceRepository marketPriceRepository - ) { + public RebalanceOrderUsecase(AccountRepository accountRepository, PortfolioRepository portfolioRepository) { this.accountRepository = accountRepository; this.portfolioRepository = portfolioRepository; - this.marketPriceRepository = marketPriceRepository; } public CompletableFuture run(Input input) { @@ -48,11 +40,10 @@ public CompletableFuture run(Input input) { 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); - })); + return portfolioRepository.get().thenCompose(portfolio -> { + Account updated = account.rebalance(portfolio); + return accountRepository.upsert(input.userId(), updated); + }); }); } } diff --git a/java8/src/main/java/folio/codinginterview/domain/Account.java b/java8/src/main/java/folio/codinginterview/domain/Account.java index 779193b..c2ae6b6 100644 --- a/java8/src/main/java/folio/codinginterview/domain/Account.java +++ b/java8/src/main/java/folio/codinginterview/domain/Account.java @@ -1,10 +1,16 @@ package folio.codinginterview.domain; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; +// 顧客の口座を表す。現金と銘柄ごとの保有額を保持する。 public final class Account { private final BigDecimal cash; private final List stocks; @@ -17,4 +23,97 @@ public Account(BigDecimal cash, List stocks) { public BigDecimal cash() { return cash; } public List stocks() { return stocks; } + + // 円未満を切り捨てる(資産配分はすべて円単位で行う)。 + private static BigDecimal floor0(BigDecimal x) { + return x.setScale(0, RoundingMode.DOWN); + } + + // 口座の総資産(現金 + 各銘柄の保有額)を返す。 + public BigDecimal total() { + BigDecimal total = cash; + for (Stock s : stocks) { + total = total.add(s.amountJpy()); + } + return total; + } + + // 新規注文額を、最適ポートフォリオに沿って配分した口座を生成する。 + public static Account openAccount(BigDecimal amount, Portfolio portfolio) { + BigDecimal cashFromRate = floor0(amount.multiply(AppConstants.CASH_RATE)); + BigDecimal investable = amount.subtract(cashFromRate); + + List stocks = new ArrayList<>(); + BigDecimal usedForStocks = BigDecimal.ZERO; + for (PortfolioItem item : portfolio.items()) { + BigDecimal amt = floor0(investable.multiply(item.rate())); + stocks.add(new Stock(item.symbol(), amt)); + usedForStocks = usedForStocks.add(amt); + } + + BigDecimal residual = investable.subtract(usedForStocks); + return new Account(cashFromRate.add(residual), stocks); + } + + // 追加注文額を口座へ反映する。最適ポートフォリオの目標額を下回らない範囲で + // 既存の保有額を維持し、ポートフォリオ外の銘柄はそのまま保持する。 + public Account addFunds(BigDecimal amount, Portfolio portfolio) { + BigDecimal totalAfter = this.total().add(amount); + BigDecimal targetCash = floor0(totalAfter.multiply(AppConstants.CASH_RATE)); + BigDecimal investable = totalAfter.subtract(targetCash); + + Map currentAmount = new HashMap<>(); + for (Stock s : stocks) { + currentAmount.put(s.symbol(), s.amountJpy()); + } + + Set portfolioSymbols = new HashSet<>(); + for (PortfolioItem item : portfolio.items()) { + portfolioSymbols.add(item.symbol()); + } + + List newPortfolioStocks = new ArrayList<>(); + for (PortfolioItem item : portfolio.items()) { + BigDecimal target = floor0(investable.multiply(item.rate())); + BigDecimal current = currentAmount.containsKey(item.symbol()) ? currentAmount.get(item.symbol()) : BigDecimal.ZERO; + BigDecimal finalAmt = current.compareTo(target) > 0 ? current : target; + newPortfolioStocks.add(new Stock(item.symbol(), finalAmt)); + } + + List preservedStocks = new ArrayList<>(); + for (Stock s : stocks) { + if (!portfolioSymbols.contains(s.symbol())) { + preservedStocks.add(s); + } + } + + List allStocks = new ArrayList<>(); + allStocks.addAll(newPortfolioStocks); + allStocks.addAll(preservedStocks); + + BigDecimal finalAmount = BigDecimal.ZERO; + for (Stock s : allStocks) { + finalAmount = finalAmount.add(s.amountJpy()); + } + BigDecimal finalCash = totalAfter.subtract(finalAmount); + + return new Account(finalCash, allStocks); + } + + // 保有資産を最適ポートフォリオの比率に近づける。 + public Account rebalance(Portfolio portfolio) { + // XXX this implementation might not be correct + BigDecimal investable = this.total(); + + List newStocks = new ArrayList<>(); + BigDecimal usedForStocks = BigDecimal.ZERO; + for (PortfolioItem item : portfolio.items()) { + BigDecimal amt = floor0(investable.multiply(item.rate())); + newStocks.add(new Stock(item.symbol(), amt)); + usedForStocks = usedForStocks.add(amt); + } + + BigDecimal finalCash = investable.subtract(usedForStocks); + return new Account(finalCash, newStocks); + } } diff --git a/java8/src/main/java/folio/codinginterview/domain/AppConstants.java b/java8/src/main/java/folio/codinginterview/domain/AppConstants.java index e32cb13..ac3c1fb 100644 --- a/java8/src/main/java/folio/codinginterview/domain/AppConstants.java +++ b/java8/src/main/java/folio/codinginterview/domain/AppConstants.java @@ -3,9 +3,7 @@ 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() {} @@ -17,17 +15,8 @@ private AppConstants() {} 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 index cb56ec0..df25bb1 100644 --- a/java8/src/main/java/folio/codinginterview/domain/Portfolio.java +++ b/java8/src/main/java/folio/codinginterview/domain/Portfolio.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Set; +/** 最適ポートフォリオ(銘柄ごとの構成比率)を表す。 */ public final class Portfolio { private final List items; diff --git a/java8/src/main/java/folio/codinginterview/domain/PortfolioItem.java b/java8/src/main/java/folio/codinginterview/domain/PortfolioItem.java index 3994326..74a386d 100644 --- a/java8/src/main/java/folio/codinginterview/domain/PortfolioItem.java +++ b/java8/src/main/java/folio/codinginterview/domain/PortfolioItem.java @@ -2,6 +2,7 @@ import java.math.BigDecimal; +/** ポートフォリオの銘柄ごとの構成比率を表す。 */ public final class PortfolioItem { private final StockSymbol symbol; private final BigDecimal rate; diff --git a/java8/src/main/java/folio/codinginterview/domain/Stock.java b/java8/src/main/java/folio/codinginterview/domain/Stock.java index b1759d7..34d04f4 100644 --- a/java8/src/main/java/folio/codinginterview/domain/Stock.java +++ b/java8/src/main/java/folio/codinginterview/domain/Stock.java @@ -2,16 +2,17 @@ import java.math.BigDecimal; +// 銘柄と保有額を表す。 public final class Stock { private final StockSymbol symbol; - private final BigDecimal qty; + private final BigDecimal amountJpy; - public Stock(StockSymbol symbol, BigDecimal qty) { + public Stock(StockSymbol symbol, BigDecimal amountJpy) { this.symbol = symbol; - this.qty = qty; + this.amountJpy = amountJpy; } public StockSymbol symbol() { return symbol; } - public BigDecimal qty() { return qty; } + public BigDecimal amountJpy() { return amountJpy; } } diff --git a/java8/src/main/java/folio/codinginterview/domain/StockSymbol.java b/java8/src/main/java/folio/codinginterview/domain/StockSymbol.java index 8a18e18..e8ec055 100644 --- a/java8/src/main/java/folio/codinginterview/domain/StockSymbol.java +++ b/java8/src/main/java/folio/codinginterview/domain/StockSymbol.java @@ -2,6 +2,7 @@ import java.util.Optional; +/** 銘柄を表す。 */ public enum StockSymbol { Toyopa, Somy; diff --git a/java8/src/main/java/folio/codinginterview/domain/UserId.java b/java8/src/main/java/folio/codinginterview/domain/UserId.java index 018b02f..1f9ca67 100644 --- a/java8/src/main/java/folio/codinginterview/domain/UserId.java +++ b/java8/src/main/java/folio/codinginterview/domain/UserId.java @@ -2,6 +2,7 @@ import java.util.Objects; +/** ユーザーIDを表す。 */ public final class UserId { private final String 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 deleted file mode 100644 index fb4f676..0000000 --- a/java8/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java +++ /dev/null @@ -1,28 +0,0 @@ -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/server/DummyServer.java b/java8/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java index a9ff784..f6e7160 100644 --- a/java8/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java +++ b/java8/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java @@ -1,17 +1,14 @@ 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.NewOrderUsecase; 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; @@ -19,18 +16,15 @@ 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 + OrderController orderController ) { this.assetController = assetController; this.portfolioController = portfolioController; this.orderController = orderController; - this.marketPriceController = marketPriceController; } public AssetController assetController() { return assetController; } @@ -39,30 +33,21 @@ public DummyServer( 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); + GetAssetUsecase getAssetUsecase = new GetAssetUsecase(accountRepository); 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); + NewOrderUsecase newOrderUsecase = new NewOrderUsecase(accountRepository, portfolioRepository); + AdditionalBuyOrderUsecase additionalBuyOrderUsecase = new AdditionalBuyOrderUsecase(accountRepository, portfolioRepository); + RebalanceOrderUsecase rebalanceOrderUsecase = new RebalanceOrderUsecase(accountRepository, portfolioRepository); AssetController assetController = new AssetController(getAssetUsecase); PortfolioController portfolioController = new PortfolioController(getLatestPortfolioUsecase, updatePortfolioUsecase); - OrderController orderController = new OrderController( - newContributionOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase); - MarketPriceController marketPriceController = new MarketPriceController(updateMarketPriceUsecase); + OrderController orderController = new OrderController(newOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase); - return new DummyServer(assetController, portfolioController, orderController, marketPriceController); + return new DummyServer(assetController, portfolioController, orderController); } } diff --git a/java8/src/main/java/folio/codinginterview/presentation/AssetController.java b/java8/src/main/java/folio/codinginterview/presentation/AssetController.java index 5cf2840..c8e24e1 100644 --- a/java8/src/main/java/folio/codinginterview/presentation/AssetController.java +++ b/java8/src/main/java/folio/codinginterview/presentation/AssetController.java @@ -12,13 +12,13 @@ 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) { + private final String amountJpy; + public StockDto(String symbol, String amountJpy) { this.symbol = symbol; - this.evaluationAmount = evaluationAmount; + this.amountJpy = amountJpy; } public String symbol() { return symbol; } - public String evaluationAmount() { return evaluationAmount; } + public String amountJpy() { return amountJpy; } } public static final class GetAssetRequest { @@ -57,7 +57,7 @@ public CompletableFuture getAsset(GetAssetRequest req) { } List stocks = new ArrayList<>(); for (GetAssetUsecase.StockOutput e : out.stocks()) { - stocks.add(new StockDto(e.symbol().toString(), e.evaluationAmount().toString())); + stocks.add(new StockDto(e.symbol().toString(), e.amountJpy().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 deleted file mode 100644 index 2a6a7ef..0000000 --- a/java8/src/main/java/folio/codinginterview/presentation/MarketPriceController.java +++ /dev/null @@ -1,61 +0,0 @@ -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 index 63450aa..f1c7f63 100644 --- a/java8/src/main/java/folio/codinginterview/presentation/OrderController.java +++ b/java8/src/main/java/folio/codinginterview/presentation/OrderController.java @@ -1,7 +1,7 @@ package folio.codinginterview.presentation; import folio.codinginterview.application.usecase.order.AdditionalBuyOrderUsecase; -import folio.codinginterview.application.usecase.order.NewContributionOrderUsecase; +import folio.codinginterview.application.usecase.order.NewOrderUsecase; import folio.codinginterview.application.usecase.order.RebalanceOrderUsecase; import folio.codinginterview.presentation.PresentationException.BadRequestException; @@ -9,10 +9,10 @@ import java.util.concurrent.CompletionException; public final class OrderController extends PresentationPreparation { - public static final class NewContributionOrderRequest { + public static final class NewOrderRequest { private final String userId; private final String amount; - public NewContributionOrderRequest(String userId, String amount) { + public NewOrderRequest(String userId, String amount) { this.userId = userId; this.amount = amount; } @@ -20,10 +20,10 @@ public NewContributionOrderRequest(String userId, String amount) { public String amount() { return amount; } } - public static final class AdditionalContributionOrderRequest { + public static final class AdditionalOrderRequest { private final String userId; private final String amount; - public AdditionalContributionOrderRequest(String userId, String amount) { + public AdditionalOrderRequest(String userId, String amount) { this.userId = userId; this.amount = amount; } @@ -37,31 +37,31 @@ public static final class RebalanceOrderRequest { public String userId() { return userId; } } - private final NewContributionOrderUsecase newContributionOrderUsecase; + private final NewOrderUsecase newOrderUsecase; private final AdditionalBuyOrderUsecase additionalBuyOrderUsecase; private final RebalanceOrderUsecase rebalanceOrderUsecase; public OrderController( - NewContributionOrderUsecase newContributionOrderUsecase, + NewOrderUsecase newOrderUsecase, AdditionalBuyOrderUsecase additionalBuyOrderUsecase, RebalanceOrderUsecase rebalanceOrderUsecase ) { - this.newContributionOrderUsecase = newContributionOrderUsecase; + this.newOrderUsecase = newOrderUsecase; this.additionalBuyOrderUsecase = additionalBuyOrderUsecase; this.rebalanceOrderUsecase = rebalanceOrderUsecase; } - public CompletableFuture newContributionOrder(NewContributionOrderRequest req) { + public CompletableFuture newOrder(NewOrderRequest req) { return parseUserId(req.userId()).thenCompose(uid -> parseAmount(req.amount()).thenCompose(amt -> - newContributionOrderUsecase.run(new NewContributionOrderUsecase.Input(uid, amt)) + newOrderUsecase.run(new NewOrderUsecase.Input(uid, amt)) .handle((v, ex) -> { if (ex != null) { Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; - if (cause instanceof NewContributionOrderUsecase.UserAlreadyExists) { + if (cause instanceof NewOrderUsecase.UserAlreadyExists) { throw new CompletionException(new BadRequestException("user already has account")); } - if (cause instanceof NewContributionOrderUsecase.AmountTooSmall) { + if (cause instanceof NewOrderUsecase.AmountTooSmall) { throw new CompletionException(new BadRequestException("amount is too small")); } throw new CompletionException(cause); @@ -70,7 +70,7 @@ public CompletableFuture newContributionOrder(NewContributionOrderRequest }))); } - public CompletableFuture additionalContributionOrder(AdditionalContributionOrderRequest req) { + public CompletableFuture additionalOrder(AdditionalOrderRequest req) { return parseUserId(req.userId()).thenCompose(uid -> parseAmount(req.amount()).thenCompose(amt -> additionalBuyOrderUsecase.run(new AdditionalBuyOrderUsecase.Input(uid, amt)) diff --git a/java8/src/test/java/folio/codinginterview/OrderScenarioTest.java b/java8/src/test/java/folio/codinginterview/OrderScenarioTest.java index 491dd58..a35f6ac 100644 --- a/java8/src/test/java/folio/codinginterview/OrderScenarioTest.java +++ b/java8/src/test/java/folio/codinginterview/OrderScenarioTest.java @@ -2,7 +2,6 @@ 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; @@ -30,19 +29,14 @@ private static void assertBigDecimalEquals(BigDecimal expected, BigDecimal actua 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 + // initialize 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) { @@ -54,7 +48,7 @@ private Throwable unwrap(Throwable e) { } @Test - void 新規拠出追加拠出リバランスの一連の操作が正しく機能する() throws Exception { + void 新規注文追加注文リバランスの一連の操作が正しく機能する() throws Exception { String userId = UUID.randomUUID().toString(); // Given: 存在しないユーザーで資産を取得しようとする @@ -74,41 +68,41 @@ private Throwable unwrap(Throwable e) { new PortfolioController.PortfolioItemDto("Somy", "0.60") ))).get(); - // And: 新規拠出を 100,000 円で注文する - oc.newContributionOrder(new OrderController.NewContributionOrderRequest(userId, "100000")).get(); + // And: 新規注文を 100,000 円で注文する + oc.newOrder(new OrderController.NewOrderRequest(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())); + total1 = total1.add(new BigDecimal(e.amountJpy())); } assertTrue(total1.subtract(new BigDecimal(100000)).abs().compareTo(new BigDecimal(2)) <= 0); - // Then: 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる + // 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("38000"), new BigDecimal(asset1Toyopa.amountJpy())); + assertBigDecimalEquals(new BigDecimal("57000"), new BigDecimal(asset1Somy.amountJpy())); assertBigDecimalEquals(new BigDecimal("5000"), new BigDecimal(asset1.cashAmount())); - // When: 追加拠出を 100,000 円で注文する - oc.additionalContributionOrder(new OrderController.AdditionalContributionOrderRequest(userId, "100000")).get(); + // When: 追加注文を 100,000 円で注文する + oc.additionalOrder(new OrderController.AdditionalOrderRequest(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())); + total2 = total2.add(new BigDecimal(e.amountJpy())); } assertTrue(total2.subtract(new BigDecimal(200000)).abs().compareTo(new BigDecimal(4)) <= 0); - // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる + // 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("76000"), new BigDecimal(asset2Toyopa.amountJpy())); + assertBigDecimalEquals(new BigDecimal("114000"), new BigDecimal(asset2Somy.amountJpy())); assertBigDecimalEquals(new BigDecimal("10000"), new BigDecimal(asset2.cashAmount())); // When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする @@ -122,15 +116,15 @@ private Throwable unwrap(Throwable e) { 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())); + total3 = total3.add(new BigDecimal(e.amountJpy())); } assertTrue(total3.subtract(total2).abs().compareTo(new BigDecimal(4)) <= 0); - // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる + // 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("19000"), new BigDecimal(asset3Toyopa.amountJpy())); + assertBigDecimalEquals(new BigDecimal("171000"), new BigDecimal(asset3Somy.amountJpy())); assertBigDecimalEquals(new BigDecimal("10000"), new BigDecimal(asset3.cashAmount())); } } From 56f50dae60a4a7f081f84f4f289179891675320f Mon Sep 17 00:00:00 2001 From: krrrr38 Date: Sat, 20 Jun 2026 05:31:44 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor(java17):=20=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=81=AE=E5=89=8A=E9=99=A4=E3=81=A8=E3=83=89?= =?UTF-8?q?=E3=83=A1=E3=82=A4=E3=83=B3=E3=83=A2=E3=83=87=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=81=AB=E3=82=88=E3=82=8B=E7=B0=A1=E7=95=A5?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: krrrr38 --- java17/README.md | 35 +++-- .../repository/MarketPriceRepository.java | 16 --- .../application/service/AssetService.java | 28 ---- .../application/service/PortfolioService.java | 130 ------------------ .../usecase/asset/GetAssetUsecase.java | 20 +-- .../UpdateMarketPriceUsecase.java | 30 ---- .../order/AdditionalBuyOrderUsecase.java | 16 +-- ...OrderUsecase.java => NewOrderUsecase.java} | 22 +-- .../usecase/order/RebalanceOrderUsecase.java | 16 +-- .../folio/codinginterview/domain/Account.java | 95 +++++++++++++ .../codinginterview/domain/AppConstants.java | 11 -- .../codinginterview/domain/Portfolio.java | 1 + .../codinginterview/domain/PortfolioItem.java | 1 + .../folio/codinginterview/domain/Stock.java | 3 +- .../codinginterview/domain/StockSymbol.java | 1 + .../folio/codinginterview/domain/UserId.java | 1 + .../repository/MarketPriceRepositoryImpl.java | 26 ---- .../infrastructure/server/DummyServer.java | 31 ++--- .../presentation/AssetController.java | 4 +- .../presentation/MarketPriceController.java | 45 ------ .../presentation/OrderController.java | 22 +-- .../codinginterview/OrderScenarioTest.java | 40 +++--- 22 files changed, 180 insertions(+), 414 deletions(-) delete mode 100644 java17/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java delete mode 100644 java17/src/main/java/folio/codinginterview/application/service/AssetService.java delete mode 100644 java17/src/main/java/folio/codinginterview/application/service/PortfolioService.java delete mode 100644 java17/src/main/java/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.java rename java17/src/main/java/folio/codinginterview/application/usecase/order/{NewContributionOrderUsecase.java => NewOrderUsecase.java} (70%) delete mode 100644 java17/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java delete mode 100644 java17/src/main/java/folio/codinginterview/presentation/MarketPriceController.java diff --git a/java17/README.md b/java17/README.md index fdc9906..ce5eaca 100644 --- a/java17/README.md +++ b/java17/README.md @@ -21,33 +21,32 @@ git init && git add . && git commit -m init Repository はモック実装としてin-memoryにデータを保持していますが、RDBを使う想定で回答してください。 -### 株と評価額 +### 銘柄と保有額 -- 株には株数(qty)があります(例: 1株、2株) -- 株には1株あたりの市場価格があります(例: 1株あたり100円) - - 例: 顧客が5株保有している場合、評価額はこの時点では `5株 × 100円 = 500円` となります +- 顧客は銘柄ごとに保有額(円)を保持します(例: A銘柄を 500 円分保有する) + - 簡略化のため、株数や市場価格は扱わず、各銘柄を金額(円)で直接保有するものとします ### ロボアドバイザーサービス - **顧客の口座** - - 新規拠出を行うと、口座がすぐに開きます + - 新規注文を行うと、口座がすぐに開きます - 口座の中で資産を管理することになります - **顧客の資産** - - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します - - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円分をいくつかの株で保有する - - 株は価格で保持するのではなく、株数で保持します - - そのため、市場価格に応じて評価額は変わることになります + - 顧客は現金と銘柄を保有し、総資産の5%は常に現金で保持します + - 例: 総資産105万円のうち5万円を現金として保持し、残り100万円分をいくつかの銘柄で保有する + - 各銘柄毎の資産は金額(円)で保持します - **最適ポートフォリオ** - - サービスが管理する、株の評価額ベースの構成比率 - - 例: A株を30%・B株を70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30%分の株数 + B株95万円*70%分の株数 になるように努める - - 購入時・売却時・リバランス時には、売買後の資産比率が __現在の最適ポートフォリオ__ に近づける形での売買を実施します -- **株の売買** - - 本アプリケーションでは、注文APIを叩くと __即時__ 株の売買が成立し資産に反映出来るものとします + - サービスが管理する、銘柄の保有額ベースの構成比率(現金は含めない) + - 例: A銘柄を30%・B銘柄を70%で保有する場合、総資産105万円のうち 5万円の現金 + A銘柄30万円分 + B銘柄70万円分 になるように努める + - 購入時・売却時・リバランス時には、注文後の資産比率が __現在の最適ポートフォリオ__ に近づける形での調整を実施します +- **資産の調整** + - 本アプリケーションでは、注文APIを叩くと __即時__ 売買が成立し資産に反映出来るものとします - 用語 - - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 - - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 - - 全売却注文: 運用中の株を全て売却すること。 - - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + - 新規注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加注文: 追加で資金を投入すること。この注文を入れると、運用する金額が増える。 + - 全売却注文: 運用中の銘柄を全て売却すること。 + - リバランス注文: 運用されている資産を、サービスで保有する最適ポートフォリオに近づけるよう調整すること。最適ポートフォリオの比率が変更された場合に、その比率へ寄せる。 + - 例: 最適ポートフォリオを `A銘柄30%+B銘柄70%` から `A銘柄50%+B銘柄50%` に変えてからリバランス注文をすると、顧客の口座も最新の最適ポートフォリオ通りの内容になる ## 確認観点 diff --git a/java17/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java b/java17/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java deleted file mode 100644 index 499c187..0000000 --- a/java17/src/main/java/folio/codinginterview/application/repository/MarketPriceRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -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/service/AssetService.java b/java17/src/main/java/folio/codinginterview/application/service/AssetService.java deleted file mode 100644 index 89b0f32..0000000 --- a/java17/src/main/java/folio/codinginterview/application/service/AssetService.java +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 474f0c5..0000000 --- a/java17/src/main/java/folio/codinginterview/application/service/PortfolioService.java +++ /dev/null @@ -1,130 +0,0 @@ -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 index 83eb41a..988bafa 100644 --- a/java17/src/main/java/folio/codinginterview/application/usecase/asset/GetAssetUsecase.java +++ b/java17/src/main/java/folio/codinginterview/application/usecase/asset/GetAssetUsecase.java @@ -1,8 +1,6 @@ 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; @@ -15,7 +13,7 @@ public final class GetAssetUsecase { public record Input(UserId userId) {} - public record StockOutput(StockSymbol symbol, BigDecimal evaluationAmount) {} + public record StockOutput(StockSymbol symbol, BigDecimal amountJpy) {} public record Output(BigDecimal cashAmount, List stocks) {} @@ -29,11 +27,9 @@ public static final class UserNotFound extends Exception { } private final AccountRepository accountRepository; - private final MarketPriceRepository marketPriceRepository; - public GetAssetUsecase(AccountRepository accountRepository, MarketPriceRepository marketPriceRepository) { + public GetAssetUsecase(AccountRepository accountRepository) { this.accountRepository = accountRepository; - this.marketPriceRepository = marketPriceRepository; } public CompletableFuture run(Input input) { @@ -44,13 +40,11 @@ public CompletableFuture run(Input input) { 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); - }); + List stocks = new ArrayList<>(); + for (var e : account.stocks()) { + stocks.add(new StockOutput(e.symbol(), e.amountJpy())); + } + return CompletableFuture.completedFuture(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 deleted file mode 100644 index eaf1e3a..0000000 --- a/java17/src/main/java/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.java +++ /dev/null @@ -1,30 +0,0 @@ -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 index cc72801..fce8ce5 100644 --- a/java17/src/main/java/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.java +++ b/java17/src/main/java/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.java @@ -1,9 +1,7 @@ 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; @@ -29,16 +27,13 @@ public static final class AmountTooSmall extends Exception { private final AccountRepository accountRepository; private final PortfolioRepository portfolioRepository; - private final MarketPriceRepository marketPriceRepository; public AdditionalBuyOrderUsecase( AccountRepository accountRepository, - PortfolioRepository portfolioRepository, - MarketPriceRepository marketPriceRepository + PortfolioRepository portfolioRepository ) { this.accountRepository = accountRepository; this.portfolioRepository = portfolioRepository; - this.marketPriceRepository = marketPriceRepository; } public CompletableFuture run(Input input) { @@ -54,11 +49,10 @@ public CompletableFuture run(Input input) { 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); - })); + return portfolioRepository.get().thenCompose(portfolio -> { + var updated = account.addFunds(input.amount(), portfolio); + 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/NewOrderUsecase.java similarity index 70% rename from java17/src/main/java/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.java rename to java17/src/main/java/folio/codinginterview/application/usecase/order/NewOrderUsecase.java index 6e84bae..279c9c0 100644 --- a/java17/src/main/java/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.java +++ b/java17/src/main/java/folio/codinginterview/application/usecase/order/NewOrderUsecase.java @@ -1,16 +1,15 @@ 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 final class NewOrderUsecase { public record Input(UserId userId, BigDecimal amount) {} public static class Exception extends RuntimeException { @@ -29,16 +28,10 @@ public static final class AmountTooSmall extends Exception { private final AccountRepository accountRepository; private final PortfolioRepository portfolioRepository; - private final MarketPriceRepository marketPriceRepository; - public NewContributionOrderUsecase( - AccountRepository accountRepository, - PortfolioRepository portfolioRepository, - MarketPriceRepository marketPriceRepository - ) { + public NewOrderUsecase(AccountRepository accountRepository, PortfolioRepository portfolioRepository) { this.accountRepository = accountRepository; this.portfolioRepository = portfolioRepository; - this.marketPriceRepository = marketPriceRepository; } public CompletableFuture run(Input input) { @@ -53,11 +46,10 @@ public CompletableFuture run(Input input) { 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); - })); + return portfolioRepository.get().thenCompose(portfolio -> { + var account = Account.openAccount(input.amount(), portfolio); + 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 index 13048af..4fbd604 100644 --- a/java17/src/main/java/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.java +++ b/java17/src/main/java/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.java @@ -1,9 +1,7 @@ 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; @@ -22,16 +20,13 @@ public static final class UserNotFound extends Exception { private final AccountRepository accountRepository; private final PortfolioRepository portfolioRepository; - private final MarketPriceRepository marketPriceRepository; public RebalanceOrderUsecase( AccountRepository accountRepository, - PortfolioRepository portfolioRepository, - MarketPriceRepository marketPriceRepository + PortfolioRepository portfolioRepository ) { this.accountRepository = accountRepository; this.portfolioRepository = portfolioRepository; - this.marketPriceRepository = marketPriceRepository; } public CompletableFuture run(Input input) { @@ -42,11 +37,10 @@ public CompletableFuture run(Input input) { 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); - })); + return portfolioRepository.get().thenCompose(portfolio -> { + var updated = account.rebalance(portfolio); + return accountRepository.upsert(input.userId(), updated); + }); }); } } diff --git a/java17/src/main/java/folio/codinginterview/domain/Account.java b/java17/src/main/java/folio/codinginterview/domain/Account.java index ed574a2..c27e8fc 100644 --- a/java17/src/main/java/folio/codinginterview/domain/Account.java +++ b/java17/src/main/java/folio/codinginterview/domain/Account.java @@ -1,10 +1,105 @@ package folio.codinginterview.domain; 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 record Account(BigDecimal cash, List stocks) { public Account { stocks = List.copyOf(stocks); } + + /** 円未満を切り捨てる(資産配分はすべて円単位で行う)。 */ + private static BigDecimal floor0(BigDecimal x) { + return x.setScale(0, RoundingMode.DOWN); + } + + /** 口座の総資産(現金 + 各銘柄の保有額)を返す。 */ + public BigDecimal total() { + BigDecimal total = cash; + for (Stock s : stocks) { + total = total.add(s.amountJpy()); + } + return total; + } + + /** 新規注文額を、最適ポートフォリオに沿って配分した口座を生成する。 */ + public static Account openAccount(BigDecimal amount, Portfolio portfolio) { + BigDecimal cashFromRate = floor0(amount.multiply(AppConstants.CASH_RATE)); + BigDecimal investable = amount.subtract(cashFromRate); + List stocks = new ArrayList<>(); + BigDecimal usedForStocks = BigDecimal.ZERO; + for (PortfolioItem item : portfolio.items()) { + BigDecimal amt = floor0(investable.multiply(item.rate())); + stocks.add(new Stock(item.symbol(), amt)); + usedForStocks = usedForStocks.add(amt); + } + BigDecimal residual = investable.subtract(usedForStocks); + return new Account(cashFromRate.add(residual), stocks); + } + + /** 追加注文額を口座へ反映する。最適ポートフォリオの目標額を下回らない範囲で + * 既存の保有額を維持し、ポートフォリオ外の銘柄はそのまま保持する。 */ + public Account addFunds(BigDecimal amount, Portfolio portfolio) { + BigDecimal totalAfter = this.total().add(amount); + BigDecimal targetCash = floor0(totalAfter.multiply(AppConstants.CASH_RATE)); + BigDecimal investable = totalAfter.subtract(targetCash); + + Map currentAmount = new HashMap<>(); + for (Stock s : stocks) { + currentAmount.put(s.symbol(), s.amountJpy()); + } + + Set portfolioSymbols = new HashSet<>(); + for (PortfolioItem item : portfolio.items()) { + portfolioSymbols.add(item.symbol()); + } + + List newPortfolioStocks = new ArrayList<>(); + for (PortfolioItem item : portfolio.items()) { + BigDecimal target = floor0(investable.multiply(item.rate())); + BigDecimal current = currentAmount.getOrDefault(item.symbol(), BigDecimal.ZERO); + BigDecimal finalAmt = current.compareTo(target) > 0 ? current : target; + newPortfolioStocks.add(new Stock(item.symbol(), finalAmt)); + } + + List preservedStocks = new ArrayList<>(); + for (Stock s : stocks) { + if (!portfolioSymbols.contains(s.symbol())) { + preservedStocks.add(s); + } + } + + List allStocks = new ArrayList<>(); + allStocks.addAll(newPortfolioStocks); + allStocks.addAll(preservedStocks); + + BigDecimal finalAmount = BigDecimal.ZERO; + for (Stock s : allStocks) { + finalAmount = finalAmount.add(s.amountJpy()); + } + BigDecimal finalCash = totalAfter.subtract(finalAmount); + return new Account(finalCash, allStocks); + } + + /** 保有資産を最適ポートフォリオの比率に近づける。 */ + public Account rebalance(Portfolio portfolio) { + // XXX this implementation might not be correct + BigDecimal investable = this.total(); + List newStocks = new ArrayList<>(); + BigDecimal usedForStocks = BigDecimal.ZERO; + for (PortfolioItem item : portfolio.items()) { + BigDecimal amt = floor0(investable.multiply(item.rate())); + newStocks.add(new Stock(item.symbol(), amt)); + usedForStocks = usedForStocks.add(amt); + } + BigDecimal finalCash = investable.subtract(usedForStocks); + return new Account(finalCash, newStocks); + } } diff --git a/java17/src/main/java/folio/codinginterview/domain/AppConstants.java b/java17/src/main/java/folio/codinginterview/domain/AppConstants.java index 372b1e4..4539279 100644 --- a/java17/src/main/java/folio/codinginterview/domain/AppConstants.java +++ b/java17/src/main/java/folio/codinginterview/domain/AppConstants.java @@ -1,9 +1,7 @@ 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() {} @@ -14,17 +12,8 @@ private AppConstants() {} 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 index ec4e13a..59a58d6 100644 --- a/java17/src/main/java/folio/codinginterview/domain/Portfolio.java +++ b/java17/src/main/java/folio/codinginterview/domain/Portfolio.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Set; +/** 最適ポートフォリオ(銘柄ごとの構成比率)を表す。 */ public record Portfolio(List items) { public Portfolio { if (items == null || items.isEmpty()) { diff --git a/java17/src/main/java/folio/codinginterview/domain/PortfolioItem.java b/java17/src/main/java/folio/codinginterview/domain/PortfolioItem.java index e45c87e..78ff8fa 100644 --- a/java17/src/main/java/folio/codinginterview/domain/PortfolioItem.java +++ b/java17/src/main/java/folio/codinginterview/domain/PortfolioItem.java @@ -2,5 +2,6 @@ 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 index 78eb63b..34ff12d 100644 --- a/java17/src/main/java/folio/codinginterview/domain/Stock.java +++ b/java17/src/main/java/folio/codinginterview/domain/Stock.java @@ -2,5 +2,6 @@ import java.math.BigDecimal; -public record Stock(StockSymbol symbol, BigDecimal qty) { +/** 保有銘柄(銘柄と保有額)を表す。 */ +public record Stock(StockSymbol symbol, BigDecimal amountJpy) { } diff --git a/java17/src/main/java/folio/codinginterview/domain/StockSymbol.java b/java17/src/main/java/folio/codinginterview/domain/StockSymbol.java index 7db6b29..d8148ce 100644 --- a/java17/src/main/java/folio/codinginterview/domain/StockSymbol.java +++ b/java17/src/main/java/folio/codinginterview/domain/StockSymbol.java @@ -2,6 +2,7 @@ import java.util.Optional; +/** 銘柄を表す。 */ public enum StockSymbol { Toyopa, Somy; diff --git a/java17/src/main/java/folio/codinginterview/domain/UserId.java b/java17/src/main/java/folio/codinginterview/domain/UserId.java index a41965c..ccac0d1 100644 --- a/java17/src/main/java/folio/codinginterview/domain/UserId.java +++ b/java17/src/main/java/folio/codinginterview/domain/UserId.java @@ -1,5 +1,6 @@ package folio.codinginterview.domain; +/** ユーザーIDを表す。 */ public record UserId(String value) { public UserId { if (value == null || value.isEmpty()) { diff --git a/java17/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java b/java17/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java deleted file mode 100644 index 5e05c17..0000000 --- a/java17/src/main/java/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.java +++ /dev/null @@ -1,26 +0,0 @@ -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/server/DummyServer.java b/java17/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java index 4f07396..63e6610 100644 --- a/java17/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java +++ b/java17/src/main/java/folio/codinginterview/infrastructure/server/DummyServer.java @@ -1,17 +1,14 @@ 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.NewOrderUsecase; 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; @@ -19,18 +16,15 @@ 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 + OrderController orderController ) { this.assetController = assetController; this.portfolioController = portfolioController; this.orderController = orderController; - this.marketPriceController = marketPriceController; } public AssetController assetController() { return assetController; } @@ -39,30 +33,21 @@ public DummyServer( 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 getAssetUsecase = new GetAssetUsecase(accountRepository); 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 newOrderUsecase = new NewOrderUsecase(accountRepository, portfolioRepository); + var additionalBuyOrderUsecase = new AdditionalBuyOrderUsecase(accountRepository, portfolioRepository); + var rebalanceOrderUsecase = new RebalanceOrderUsecase(accountRepository, portfolioRepository); var assetController = new AssetController(getAssetUsecase); var portfolioController = new PortfolioController(getLatestPortfolioUsecase, updatePortfolioUsecase); - var orderController = new OrderController( - newContributionOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase); - var marketPriceController = new MarketPriceController(updateMarketPriceUsecase); + var orderController = new OrderController(newOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase); - return new DummyServer(assetController, portfolioController, orderController, marketPriceController); + return new DummyServer(assetController, portfolioController, orderController); } } diff --git a/java17/src/main/java/folio/codinginterview/presentation/AssetController.java b/java17/src/main/java/folio/codinginterview/presentation/AssetController.java index a413d84..d1184f7 100644 --- a/java17/src/main/java/folio/codinginterview/presentation/AssetController.java +++ b/java17/src/main/java/folio/codinginterview/presentation/AssetController.java @@ -9,7 +9,7 @@ import java.util.concurrent.CompletionException; public final class AssetController extends PresentationPreparation { - public record StockDto(String symbol, String evaluationAmount) {} + public record StockDto(String symbol, String amountJpy) {} public record GetAssetRequest(String userId) {} @@ -34,7 +34,7 @@ public CompletableFuture getAsset(GetAssetRequest req) { } List stocks = new ArrayList<>(); for (var e : out.stocks()) { - stocks.add(new StockDto(e.symbol().toString(), e.evaluationAmount().toString())); + stocks.add(new StockDto(e.symbol().toString(), e.amountJpy().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 deleted file mode 100644 index 3457407..0000000 --- a/java17/src/main/java/folio/codinginterview/presentation/MarketPriceController.java +++ /dev/null @@ -1,45 +0,0 @@ -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 index 6fb1275..d18257c 100644 --- a/java17/src/main/java/folio/codinginterview/presentation/OrderController.java +++ b/java17/src/main/java/folio/codinginterview/presentation/OrderController.java @@ -1,7 +1,7 @@ package folio.codinginterview.presentation; import folio.codinginterview.application.usecase.order.AdditionalBuyOrderUsecase; -import folio.codinginterview.application.usecase.order.NewContributionOrderUsecase; +import folio.codinginterview.application.usecase.order.NewOrderUsecase; import folio.codinginterview.application.usecase.order.RebalanceOrderUsecase; import folio.codinginterview.presentation.PresentationException.BadRequestException; @@ -9,37 +9,37 @@ import java.util.concurrent.CompletionException; public final class OrderController extends PresentationPreparation { - public record NewContributionOrderRequest(String userId, String amount) {} + public record NewOrderRequest(String userId, String amount) {} - public record AdditionalContributionOrderRequest(String userId, String amount) {} + public record AdditionalOrderRequest(String userId, String amount) {} public record RebalanceOrderRequest(String userId) {} - private final NewContributionOrderUsecase newContributionOrderUsecase; + private final NewOrderUsecase newOrderUsecase; private final AdditionalBuyOrderUsecase additionalBuyOrderUsecase; private final RebalanceOrderUsecase rebalanceOrderUsecase; public OrderController( - NewContributionOrderUsecase newContributionOrderUsecase, + NewOrderUsecase newOrderUsecase, AdditionalBuyOrderUsecase additionalBuyOrderUsecase, RebalanceOrderUsecase rebalanceOrderUsecase ) { - this.newContributionOrderUsecase = newContributionOrderUsecase; + this.newOrderUsecase = newOrderUsecase; this.additionalBuyOrderUsecase = additionalBuyOrderUsecase; this.rebalanceOrderUsecase = rebalanceOrderUsecase; } - public CompletableFuture newContributionOrder(NewContributionOrderRequest req) { + public CompletableFuture newOrder(NewOrderRequest req) { return parseUserId(req.userId()).thenCompose(uid -> parseAmount(req.amount()).thenCompose(amt -> - newContributionOrderUsecase.run(new NewContributionOrderUsecase.Input(uid, amt)) + newOrderUsecase.run(new NewOrderUsecase.Input(uid, amt)) .handle((v, ex) -> { if (ex != null) { Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; - if (cause instanceof NewContributionOrderUsecase.UserAlreadyExists) { + if (cause instanceof NewOrderUsecase.UserAlreadyExists) { throw new CompletionException(new BadRequestException("user already has account")); } - if (cause instanceof NewContributionOrderUsecase.AmountTooSmall) { + if (cause instanceof NewOrderUsecase.AmountTooSmall) { throw new CompletionException(new BadRequestException("amount is too small")); } throw new CompletionException(cause); @@ -48,7 +48,7 @@ public CompletableFuture newContributionOrder(NewContributionOrderRequest }))); } - public CompletableFuture additionalContributionOrder(AdditionalContributionOrderRequest req) { + public CompletableFuture additionalOrder(AdditionalOrderRequest req) { return parseUserId(req.userId()).thenCompose(uid -> parseAmount(req.amount()).thenCompose(amt -> additionalBuyOrderUsecase.run(new AdditionalBuyOrderUsecase.Input(uid, amt)) diff --git a/java17/src/test/java/folio/codinginterview/OrderScenarioTest.java b/java17/src/test/java/folio/codinginterview/OrderScenarioTest.java index e9c1e20..1ff1fe4 100644 --- a/java17/src/test/java/folio/codinginterview/OrderScenarioTest.java +++ b/java17/src/test/java/folio/codinginterview/OrderScenarioTest.java @@ -2,7 +2,6 @@ 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; @@ -28,19 +27,14 @@ private static void assertBigDecimalEquals(BigDecimal expected, BigDecimal actua 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 + // initialize 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) { @@ -72,41 +66,41 @@ private Throwable unwrap(Throwable e) { new PortfolioController.PortfolioItemDto("Somy", "0.60") ))).get(); - // And: 新規拠出を 100,000 円で注文する - oc.newContributionOrder(new OrderController.NewContributionOrderRequest(userId, "100000")).get(); + // And: 新規注文を 100,000 円で注文する + oc.newOrder(new OrderController.NewOrderRequest(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())); + total1 = total1.add(new BigDecimal(e.amountJpy())); } assertTrue(total1.subtract(new BigDecimal(100000)).abs().compareTo(new BigDecimal(2)) <= 0); - // Then: 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる + // 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("38000"), new BigDecimal(asset1Toyopa.amountJpy())); + assertBigDecimalEquals(new BigDecimal("57000"), new BigDecimal(asset1Somy.amountJpy())); assertBigDecimalEquals(new BigDecimal("5000"), new BigDecimal(asset1.cashAmount())); - // When: 追加拠出を 100,000 円で注文する - oc.additionalContributionOrder(new OrderController.AdditionalContributionOrderRequest(userId, "100000")).get(); + // When: 追加注文を 100,000 円で注文する + oc.additionalOrder(new OrderController.AdditionalOrderRequest(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())); + total2 = total2.add(new BigDecimal(e.amountJpy())); } assertTrue(total2.subtract(new BigDecimal(200000)).abs().compareTo(new BigDecimal(4)) <= 0); - // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる + // 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("76000"), new BigDecimal(asset2Toyopa.amountJpy())); + assertBigDecimalEquals(new BigDecimal("114000"), new BigDecimal(asset2Somy.amountJpy())); assertBigDecimalEquals(new BigDecimal("10000"), new BigDecimal(asset2.cashAmount())); // When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする @@ -120,15 +114,15 @@ private Throwable unwrap(Throwable e) { 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())); + total3 = total3.add(new BigDecimal(e.amountJpy())); } assertTrue(total3.subtract(total2).abs().compareTo(new BigDecimal(4)) <= 0); - // And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる + // 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("19000"), new BigDecimal(asset3Toyopa.amountJpy())); + assertBigDecimalEquals(new BigDecimal("171000"), new BigDecimal(asset3Somy.amountJpy())); assertBigDecimalEquals(new BigDecimal("10000"), new BigDecimal(asset3.cashAmount())); } } From 0bc340f46364a341f9a2c8bc1b63739eaf7ac393 Mon Sep 17 00:00:00 2001 From: krrrr38 Date: Sat, 20 Jun 2026 05:31:48 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor(php):=20=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=81=AE=E5=89=8A=E9=99=A4=E3=81=A8=E3=83=89?= =?UTF-8?q?=E3=83=A1=E3=82=A4=E3=83=B3=E3=83=A2=E3=83=87=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=81=AB=E3=82=88=E3=82=8B=E7=B0=A1=E7=95=A5?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: krrrr38 --- php/README.md | 35 +++-- .../Repository/MarketPriceRepository.php | 19 --- .../Application/Service/AssetService.php | 31 ----- .../Application/Service/PortfolioService.php | 125 ------------------ .../Usecase/Asset/GetAssetUsecase.php | 8 +- .../MarketPrice/UpdateMarketPriceUsecase.php | 37 ------ .../Order/AdditionalBuyOrderUsecase.php | 6 +- ...onOrderUsecase.php => NewOrderUsecase.php} | 21 ++- .../Usecase/Order/RebalanceOrderUsecase.php | 6 +- php/src/CodingInterview/Domain/Account.php | 83 ++++++++++++ .../CodingInterview/Domain/AppConstants.php | 9 -- php/src/CodingInterview/Domain/Portfolio.php | 1 + .../CodingInterview/Domain/PortfolioItem.php | 1 + php/src/CodingInterview/Domain/Stock.php | 3 +- .../CodingInterview/Domain/StockSymbol.php | 1 + php/src/CodingInterview/Domain/UserId.php | 1 + .../Repository/MarketPriceRepositoryImpl.php | 30 ----- .../Infrastructure/Server/DummyServer.php | 25 +--- .../Presentation/AssetController.php | 4 +- .../Presentation/MarketPriceController.php | 48 ------- .../Presentation/OrderController.php | 24 ++-- php/tests/OrderScenarioTest.php | 39 +++--- 22 files changed, 155 insertions(+), 402 deletions(-) delete mode 100644 php/src/CodingInterview/Application/Repository/MarketPriceRepository.php delete mode 100644 php/src/CodingInterview/Application/Service/AssetService.php delete mode 100644 php/src/CodingInterview/Application/Service/PortfolioService.php delete mode 100644 php/src/CodingInterview/Application/Usecase/MarketPrice/UpdateMarketPriceUsecase.php rename php/src/CodingInterview/Application/Usecase/Order/{NewContributionOrderUsecase.php => NewOrderUsecase.php} (58%) delete mode 100644 php/src/CodingInterview/Infrastructure/Repository/MarketPriceRepositoryImpl.php delete mode 100644 php/src/CodingInterview/Presentation/MarketPriceController.php diff --git a/php/README.md b/php/README.md index 206636d..9ea4c5f 100644 --- a/php/README.md +++ b/php/README.md @@ -22,33 +22,32 @@ vendor/bin/phpunit Repository はモック実装としてin-memoryにデータを保持していますが、RDBを使う想定で回答してください。 -### 株と評価額 +### 銘柄と保有額 -- 株には株数(qty)があります(例: 1株、2株) -- 株には1株あたりの市場価格があります(例: 1株あたり100円) - - 例: 顧客が5株保有している場合、評価額はこの時点では `5株 × 100円 = 500円` となります +- 顧客は銘柄ごとに保有額(円)を保持します(例: A銘柄を 500 円分保有する) + - 簡略化のため、株数や市場価格は扱わず、各銘柄を金額(円)で直接保有するものとします ### ロボアドバイザーサービス - **顧客の口座** - - 新規拠出を行うと、口座がすぐに開きます + - 新規注文を行うと、口座がすぐに開きます - 口座の中で資産を管理することになります - **顧客の資産** - - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します - - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円分をいくつかの株で保有する - - 株は価格で保持するのではなく、株数で保持します - - そのため、市場価格に応じて評価額は変わることになります + - 顧客は現金と銘柄を保有し、総資産の5%は常に現金で保持します + - 例: 総資産105万円のうち5万円を現金として保持し、残り100万円分をいくつかの銘柄で保有する + - 各銘柄毎の資産は金額(円)で保持します - **最適ポートフォリオ** - - サービスが管理する、株の評価額ベースの構成比率 - - 例: A株を30%・B株を70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30%分の株数 + B株95万円*70%分の株数 になるように努める - - 購入時・売却時・リバランス時には、売買後の資産比率が __現在の最適ポートフォリオ__ に近づける形での売買を実施します -- **株の売買** - - 本アプリケーションでは、注文APIを叩くと __即時__ 株の売買が成立し資産に反映出来るものとします + - サービスが管理する、銘柄の保有額ベースの構成比率(現金は含めない) + - 例: A銘柄を30%・B銘柄を70%で保有する場合、総資産105万円のうち 5万円の現金 + A銘柄30万円分 + B銘柄70万円分 になるように努める + - 購入時・売却時・リバランス時には、注文後の資産比率が __現在の最適ポートフォリオ__ に近づける形での調整を実施します +- **資産の調整** + - 本アプリケーションでは、注文APIを叩くと __即時__ 売買が成立し資産に反映出来るものとします - 用語 - - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 - - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 - - 全売却注文: 運用中の株を全て売却すること。 - - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + - 新規注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加注文: 追加で資金を投入すること。この注文を入れると、運用する金額が増える。 + - 全売却注文: 運用中の銘柄を全て売却すること。 + - リバランス注文: 運用されている資産を、サービスで保有する最適ポートフォリオに近づけるよう調整すること。最適ポートフォリオの比率が変更された場合に、その比率へ寄せる。 + - 例: 最適ポートフォリオを `A銘柄30%+B銘柄70%` から `A銘柄50%+B銘柄50%` に変えてからリバランス注文をすると、顧客の口座も最新の最適ポートフォリオ通りの内容になる ## 確認観点 diff --git a/php/src/CodingInterview/Application/Repository/MarketPriceRepository.php b/php/src/CodingInterview/Application/Repository/MarketPriceRepository.php deleted file mode 100644 index 94b3a21..0000000 --- a/php/src/CodingInterview/Application/Repository/MarketPriceRepository.php +++ /dev/null @@ -1,19 +0,0 @@ - keyed by StockSymbol->value */ - public function all(): array; - - /** @param array $prices */ - public function update(array $prices): void; -} diff --git a/php/src/CodingInterview/Application/Service/AssetService.php b/php/src/CodingInterview/Application/Service/AssetService.php deleted file mode 100644 index 82ad3fe..0000000 --- a/php/src/CodingInterview/Application/Service/AssetService.php +++ /dev/null @@ -1,31 +0,0 @@ - $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 deleted file mode 100644 index cdd0563..0000000 --- a/php/src/CodingInterview/Application/Service/PortfolioService.php +++ /dev/null @@ -1,125 +0,0 @@ -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 index ce2eea7..237b3f7 100644 --- a/php/src/CodingInterview/Application/Usecase/Asset/GetAssetUsecase.php +++ b/php/src/CodingInterview/Application/Usecase/Asset/GetAssetUsecase.php @@ -5,8 +5,6 @@ namespace Folio\CodingInterview\Application\Usecase\Asset; use Folio\CodingInterview\Application\Repository\AccountRepository; -use Folio\CodingInterview\Application\Repository\MarketPriceRepository; -use Folio\CodingInterview\Application\Service\AssetService; use Folio\CodingInterview\Domain\BigDecimal; use Folio\CodingInterview\Domain\StockSymbol; use Folio\CodingInterview\Domain\UserId; @@ -20,7 +18,7 @@ final class GetAssetStockOutput { public function __construct( public readonly StockSymbol $symbol, - public readonly BigDecimal $evaluationAmount, + public readonly BigDecimal $amountJpy, ) {} } @@ -37,7 +35,6 @@ final class GetAssetUsecase { public function __construct( private readonly AccountRepository $accountRepository, - private readonly MarketPriceRepository $marketPriceRepository, ) {} public function run(GetAssetUsecaseInput $input): GetAssetUsecaseOutput @@ -46,10 +43,9 @@ public function run(GetAssetUsecaseInput $input): GetAssetUsecaseOutput 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)); + $stocks[] = new GetAssetStockOutput($e->symbol, $e->amountJpy); } return new GetAssetUsecaseOutput($account->cash, $stocks); } diff --git a/php/src/CodingInterview/Application/Usecase/MarketPrice/UpdateMarketPriceUsecase.php b/php/src/CodingInterview/Application/Usecase/MarketPrice/UpdateMarketPriceUsecase.php deleted file mode 100644 index a9876df..0000000 --- a/php/src/CodingInterview/Application/Usecase/MarketPrice/UpdateMarketPriceUsecase.php +++ /dev/null @@ -1,37 +0,0 @@ -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 index 3cd6c7e..4584620 100644 --- a/php/src/CodingInterview/Application/Usecase/Order/AdditionalBuyOrderUsecase.php +++ b/php/src/CodingInterview/Application/Usecase/Order/AdditionalBuyOrderUsecase.php @@ -5,9 +5,7 @@ namespace Folio\CodingInterview\Application\Usecase\Order; use Folio\CodingInterview\Application\Repository\AccountRepository; -use Folio\CodingInterview\Application\Repository\MarketPriceRepository; use Folio\CodingInterview\Application\Repository\PortfolioRepository; -use Folio\CodingInterview\Application\Service\PortfolioService; use Folio\CodingInterview\Domain\AppConstants; use Folio\CodingInterview\Domain\BigDecimal; use Folio\CodingInterview\Domain\UserId; @@ -35,7 +33,6 @@ final class AdditionalBuyOrderUsecase public function __construct( private readonly AccountRepository $accountRepository, private readonly PortfolioRepository $portfolioRepository, - private readonly MarketPriceRepository $marketPriceRepository, ) {} public function run(AdditionalBuyOrderUsecaseInput $input): void @@ -48,8 +45,7 @@ public function run(AdditionalBuyOrderUsecaseInput $input): void throw new AdditionalBuyOrderUserNotFoundException(); } $portfolio = $this->portfolioRepository->get(); - $prices = $this->marketPriceRepository->all(); - $updated = PortfolioService::allocateAdditional($account, $input->amount, $portfolio, $prices); + $updated = $account->addFunds($input->amount, $portfolio); $this->accountRepository->upsert($input->userId, $updated); } } diff --git a/php/src/CodingInterview/Application/Usecase/Order/NewContributionOrderUsecase.php b/php/src/CodingInterview/Application/Usecase/Order/NewOrderUsecase.php similarity index 58% rename from php/src/CodingInterview/Application/Usecase/Order/NewContributionOrderUsecase.php rename to php/src/CodingInterview/Application/Usecase/Order/NewOrderUsecase.php index 17cd56b..659db46 100644 --- a/php/src/CodingInterview/Application/Usecase/Order/NewContributionOrderUsecase.php +++ b/php/src/CodingInterview/Application/Usecase/Order/NewOrderUsecase.php @@ -5,14 +5,13 @@ namespace Folio\CodingInterview\Application\Usecase\Order; use Folio\CodingInterview\Application\Repository\AccountRepository; -use Folio\CodingInterview\Application\Repository\MarketPriceRepository; use Folio\CodingInterview\Application\Repository\PortfolioRepository; -use Folio\CodingInterview\Application\Service\PortfolioService; +use Folio\CodingInterview\Domain\Account; use Folio\CodingInterview\Domain\AppConstants; use Folio\CodingInterview\Domain\BigDecimal; use Folio\CodingInterview\Domain\UserId; -final class NewContributionOrderUsecaseInput +final class NewOrderUsecaseInput { public function __construct( public readonly UserId $userId, @@ -20,35 +19,33 @@ public function __construct( ) {} } -final class NewContributionOrderUserAlreadyExistsException extends \RuntimeException +final class NewOrderUserAlreadyExistsException extends \RuntimeException { public function __construct() { parent::__construct('user already has account'); } } -final class NewContributionOrderAmountTooSmallException extends \RuntimeException +final class NewOrderAmountTooSmallException extends \RuntimeException { public function __construct() { parent::__construct('amount is too small'); } } -final class NewContributionOrderUsecase +final class NewOrderUsecase { public function __construct( private readonly AccountRepository $accountRepository, private readonly PortfolioRepository $portfolioRepository, - private readonly MarketPriceRepository $marketPriceRepository, ) {} - public function run(NewContributionOrderUsecaseInput $input): void + public function run(NewOrderUsecaseInput $input): void { if ($input->amount->lt(AppConstants::minOperationAmount())) { - throw new NewContributionOrderAmountTooSmallException(); + throw new NewOrderAmountTooSmallException(); } if ($this->accountRepository->exists($input->userId)) { - throw new NewContributionOrderUserAlreadyExistsException(); + throw new NewOrderUserAlreadyExistsException(); } $portfolio = $this->portfolioRepository->get(); - $prices = $this->marketPriceRepository->all(); - $account = PortfolioService::allocateNew($input->amount, $portfolio, $prices); + $account = Account::openAccount($input->amount, $portfolio); $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 index d40448c..1699bc5 100644 --- a/php/src/CodingInterview/Application/Usecase/Order/RebalanceOrderUsecase.php +++ b/php/src/CodingInterview/Application/Usecase/Order/RebalanceOrderUsecase.php @@ -5,9 +5,7 @@ namespace Folio\CodingInterview\Application\Usecase\Order; use Folio\CodingInterview\Application\Repository\AccountRepository; -use Folio\CodingInterview\Application\Repository\MarketPriceRepository; use Folio\CodingInterview\Application\Repository\PortfolioRepository; -use Folio\CodingInterview\Application\Service\PortfolioService; use Folio\CodingInterview\Domain\UserId; final class RebalanceOrderUsecaseInput @@ -25,7 +23,6 @@ final class RebalanceOrderUsecase public function __construct( private readonly AccountRepository $accountRepository, private readonly PortfolioRepository $portfolioRepository, - private readonly MarketPriceRepository $marketPriceRepository, ) {} public function run(RebalanceOrderUsecaseInput $input): void @@ -35,8 +32,7 @@ public function run(RebalanceOrderUsecaseInput $input): void throw new RebalanceOrderUserNotFoundException(); } $portfolio = $this->portfolioRepository->get(); - $prices = $this->marketPriceRepository->all(); - $updated = PortfolioService::rebalance($account, $portfolio, $prices); + $updated = $account->rebalance($portfolio); $this->accountRepository->upsert($input->userId, $updated); } } diff --git a/php/src/CodingInterview/Domain/Account.php b/php/src/CodingInterview/Domain/Account.php index e2392f9..23ff9f5 100644 --- a/php/src/CodingInterview/Domain/Account.php +++ b/php/src/CodingInterview/Domain/Account.php @@ -4,6 +4,7 @@ namespace Folio\CodingInterview\Domain; +/** 口座を表す。 */ final class Account { /** @param Stock[] $stocks */ @@ -12,4 +13,86 @@ public function __construct( public readonly array $stocks, ) { } + + /** 総資産(現金 + 各銘柄の保有額合計)を返す。 */ + public function total(): BigDecimal + { + $total = $this->cash; + foreach ($this->stocks as $stock) { + $total = $total->add($stock->amountJpy); + } + return $total; + } + + /** 新規注文で口座を開設する。 */ + public static function openAccount(BigDecimal $amount, Portfolio $portfolio): self + { + $cashFromRate = $amount->mul(AppConstants::cashRate())->setScaleDown(0); + $investable = $amount->sub($cashFromRate); + $stocks = []; + $usedForStocks = BigDecimal::zero(); + foreach ($portfolio->items as $item) { + $amt = $investable->mul($item->rate)->setScaleDown(0); + $stocks[] = new Stock($item->symbol, $amt); + $usedForStocks = $usedForStocks->add($amt); + } + $residual = $investable->sub($usedForStocks); + return new self($cashFromRate->add($residual), $stocks); + } + + /** 追加注文で資金を追加する。 */ + public function addFunds(BigDecimal $amount, Portfolio $portfolio): self + { + $totalAfter = $this->total()->add($amount); + $targetCash = $totalAfter->mul(AppConstants::cashRate())->setScaleDown(0); + $investable = $totalAfter->sub($targetCash); + + $currentAmounts = []; + foreach ($this->stocks as $e) { + $currentAmounts[$e->symbol->value] = $e->amountJpy; + } + + $portfolioSymbols = []; + foreach ($portfolio->items as $i) { + $portfolioSymbols[$i->symbol->value] = true; + } + + $newPortfolioStocks = []; + $usedForStocks = BigDecimal::zero(); + foreach ($portfolio->items as $item) { + $target = $investable->mul($item->rate)->setScaleDown(0); + $current = $currentAmounts[$item->symbol->value] ?? BigDecimal::zero(); + $final = $target->gt($current) ? $target : $current; + $newPortfolioStocks[] = new Stock($item->symbol, $final); + $usedForStocks = $usedForStocks->add($final); + } + + $preservedStocks = []; + foreach ($this->stocks as $e) { + if (!isset($portfolioSymbols[$e->symbol->value])) { + $preservedStocks[] = $e; + $usedForStocks = $usedForStocks->add($e->amountJpy); + } + } + + $allStocks = array_merge($newPortfolioStocks, $preservedStocks); + $finalCash = $totalAfter->sub($usedForStocks); + return new self($finalCash, $allStocks); + } + + /** リバランス注文で最適ポートフォリオに調整する。 */ + public function rebalance(Portfolio $portfolio): self + { + // XXX this implementation might not be correct + $investable = $this->total(); + $stocks = []; + $usedForStocks = BigDecimal::zero(); + foreach ($portfolio->items as $item) { + $amt = $investable->mul($item->rate)->setScaleDown(0); + $stocks[] = new Stock($item->symbol, $amt); + $usedForStocks = $usedForStocks->add($amt); + } + $finalCash = $investable->sub($usedForStocks); + return new self($finalCash, $stocks); + } } diff --git a/php/src/CodingInterview/Domain/AppConstants.php b/php/src/CodingInterview/Domain/AppConstants.php index bde08c5..84617fb 100644 --- a/php/src/CodingInterview/Domain/AppConstants.php +++ b/php/src/CodingInterview/Domain/AppConstants.php @@ -22,15 +22,6 @@ public static function supportedSymbols(): array return [StockSymbol::Toyopa, StockSymbol::Somy]; } - /** @return array 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([ diff --git a/php/src/CodingInterview/Domain/Portfolio.php b/php/src/CodingInterview/Domain/Portfolio.php index dba92c0..fbf41cc 100644 --- a/php/src/CodingInterview/Domain/Portfolio.php +++ b/php/src/CodingInterview/Domain/Portfolio.php @@ -4,6 +4,7 @@ namespace Folio\CodingInterview\Domain; +/** 最適ポートフォリオ(銘柄ごとの構成比率)を表す。 */ final class Portfolio { /** @param PortfolioItem[] $items */ diff --git a/php/src/CodingInterview/Domain/PortfolioItem.php b/php/src/CodingInterview/Domain/PortfolioItem.php index 18db8ae..9fa51a6 100644 --- a/php/src/CodingInterview/Domain/PortfolioItem.php +++ b/php/src/CodingInterview/Domain/PortfolioItem.php @@ -4,6 +4,7 @@ namespace Folio\CodingInterview\Domain; +/** ポートフォリオの銘柄ごとの構成比率を表す。 */ final class PortfolioItem { public function __construct( diff --git a/php/src/CodingInterview/Domain/Stock.php b/php/src/CodingInterview/Domain/Stock.php index b0d0d7e..89346c2 100644 --- a/php/src/CodingInterview/Domain/Stock.php +++ b/php/src/CodingInterview/Domain/Stock.php @@ -4,11 +4,12 @@ namespace Folio\CodingInterview\Domain; +/** 保有銘柄(銘柄と保有額)を表す。 */ final class Stock { public function __construct( public readonly StockSymbol $symbol, - public readonly BigDecimal $qty, + public readonly BigDecimal $amountJpy, ) { } } diff --git a/php/src/CodingInterview/Domain/StockSymbol.php b/php/src/CodingInterview/Domain/StockSymbol.php index da2f486..483fcd4 100644 --- a/php/src/CodingInterview/Domain/StockSymbol.php +++ b/php/src/CodingInterview/Domain/StockSymbol.php @@ -4,6 +4,7 @@ namespace Folio\CodingInterview\Domain; +/** 銘柄を表す。 */ enum StockSymbol: string { case Toyopa = 'Toyopa'; diff --git a/php/src/CodingInterview/Domain/UserId.php b/php/src/CodingInterview/Domain/UserId.php index 5ca9aa8..eb29496 100644 --- a/php/src/CodingInterview/Domain/UserId.php +++ b/php/src/CodingInterview/Domain/UserId.php @@ -4,6 +4,7 @@ namespace Folio\CodingInterview\Domain; +/** ユーザーIDを表す。 */ final class UserId { public function __construct(public readonly string $value) diff --git a/php/src/CodingInterview/Infrastructure/Repository/MarketPriceRepositoryImpl.php b/php/src/CodingInterview/Infrastructure/Repository/MarketPriceRepositoryImpl.php deleted file mode 100644 index cd67c28..0000000 --- a/php/src/CodingInterview/Infrastructure/Repository/MarketPriceRepositoryImpl.php +++ /dev/null @@ -1,30 +0,0 @@ - */ - 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/Server/DummyServer.php b/php/src/CodingInterview/Infrastructure/Server/DummyServer.php index 9700b3b..38700cc 100644 --- a/php/src/CodingInterview/Infrastructure/Server/DummyServer.php +++ b/php/src/CodingInterview/Infrastructure/Server/DummyServer.php @@ -5,17 +5,14 @@ namespace Folio\CodingInterview\Infrastructure\Server; use Folio\CodingInterview\Application\Usecase\Asset\GetAssetUsecase; -use Folio\CodingInterview\Application\Usecase\MarketPrice\UpdateMarketPriceUsecase; use Folio\CodingInterview\Application\Usecase\Order\AdditionalBuyOrderUsecase; -use Folio\CodingInterview\Application\Usecase\Order\NewContributionOrderUsecase; +use Folio\CodingInterview\Application\Usecase\Order\NewOrderUsecase; use Folio\CodingInterview\Application\Usecase\Order\RebalanceOrderUsecase; use Folio\CodingInterview\Application\Usecase\Portfolio\GetLatestPortfolioUsecase; use Folio\CodingInterview\Application\Usecase\Portfolio\UpdatePortfolioUsecase; use Folio\CodingInterview\Infrastructure\Repository\AccountRepositoryImpl; -use Folio\CodingInterview\Infrastructure\Repository\MarketPriceRepositoryImpl; use Folio\CodingInterview\Infrastructure\Repository\PortfolioRepositoryImpl; use Folio\CodingInterview\Presentation\AssetController; -use Folio\CodingInterview\Presentation\MarketPriceController; use Folio\CodingInterview\Presentation\OrderController; use Folio\CodingInterview\Presentation\PortfolioController; @@ -25,34 +22,24 @@ public function __construct( public readonly AssetController $assetController, public readonly PortfolioController $portfolioController, public readonly OrderController $orderController, - public readonly MarketPriceController $marketPriceController, ) {} public static function default(): DummyServer { $portfolioRepository = new PortfolioRepositoryImpl(); $accountRepository = new AccountRepositoryImpl(); - $marketPriceRepository = new MarketPriceRepositoryImpl(); - $getAssetUsecase = new GetAssetUsecase($accountRepository, $marketPriceRepository); + $getAssetUsecase = new GetAssetUsecase($accountRepository); $getLatestPortfolioUsecase = new GetLatestPortfolioUsecase($portfolioRepository); $updatePortfolioUsecase = new UpdatePortfolioUsecase($portfolioRepository); - $updateMarketPriceUsecase = new UpdateMarketPriceUsecase($marketPriceRepository); - $newContributionOrderUsecase = new NewContributionOrderUsecase( - $accountRepository, $portfolioRepository, $marketPriceRepository, - ); - $additionalBuyOrderUsecase = new AdditionalBuyOrderUsecase( - $accountRepository, $portfolioRepository, $marketPriceRepository, - ); - $rebalanceOrderUsecase = new RebalanceOrderUsecase( - $accountRepository, $portfolioRepository, $marketPriceRepository, - ); + $newOrderUsecase = new NewOrderUsecase($accountRepository, $portfolioRepository); + $additionalBuyOrderUsecase = new AdditionalBuyOrderUsecase($accountRepository, $portfolioRepository); + $rebalanceOrderUsecase = new RebalanceOrderUsecase($accountRepository, $portfolioRepository); return new DummyServer( new AssetController($getAssetUsecase), new PortfolioController($getLatestPortfolioUsecase, $updatePortfolioUsecase), - new OrderController($newContributionOrderUsecase, $additionalBuyOrderUsecase, $rebalanceOrderUsecase), - new MarketPriceController($updateMarketPriceUsecase), + new OrderController($newOrderUsecase, $additionalBuyOrderUsecase, $rebalanceOrderUsecase), ); } } diff --git a/php/src/CodingInterview/Presentation/AssetController.php b/php/src/CodingInterview/Presentation/AssetController.php index 97199c9..5d7bd1c 100644 --- a/php/src/CodingInterview/Presentation/AssetController.php +++ b/php/src/CodingInterview/Presentation/AssetController.php @@ -17,7 +17,7 @@ final class GetAssetStockDto { public function __construct( public readonly string $symbol, - public readonly string $evaluationAmount, + public readonly string $amountJpy, ) {} } @@ -46,7 +46,7 @@ public function getAsset(GetAssetRequest $req): GetAssetResponse } $stocks = []; foreach ($out->stocks as $e) { - $stocks[] = new GetAssetStockDto($e->symbol->value, $e->evaluationAmount->toString()); + $stocks[] = new GetAssetStockDto($e->symbol->value, $e->amountJpy->toString()); } return new GetAssetResponse($out->cashAmount->toString(), $stocks); } diff --git a/php/src/CodingInterview/Presentation/MarketPriceController.php b/php/src/CodingInterview/Presentation/MarketPriceController.php deleted file mode 100644 index 44c7ac1..0000000 --- a/php/src/CodingInterview/Presentation/MarketPriceController.php +++ /dev/null @@ -1,48 +0,0 @@ -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 index c8c8427..051050d 100644 --- a/php/src/CodingInterview/Presentation/OrderController.php +++ b/php/src/CodingInterview/Presentation/OrderController.php @@ -8,15 +8,15 @@ use Folio\CodingInterview\Application\Usecase\Order\AdditionalBuyOrderUserNotFoundException; use Folio\CodingInterview\Application\Usecase\Order\AdditionalBuyOrderUsecase; use Folio\CodingInterview\Application\Usecase\Order\AdditionalBuyOrderUsecaseInput; -use Folio\CodingInterview\Application\Usecase\Order\NewContributionOrderAmountTooSmallException; -use Folio\CodingInterview\Application\Usecase\Order\NewContributionOrderUserAlreadyExistsException; -use Folio\CodingInterview\Application\Usecase\Order\NewContributionOrderUsecase; -use Folio\CodingInterview\Application\Usecase\Order\NewContributionOrderUsecaseInput; +use Folio\CodingInterview\Application\Usecase\Order\NewOrderAmountTooSmallException; +use Folio\CodingInterview\Application\Usecase\Order\NewOrderUserAlreadyExistsException; +use Folio\CodingInterview\Application\Usecase\Order\NewOrderUsecase; +use Folio\CodingInterview\Application\Usecase\Order\NewOrderUsecaseInput; use Folio\CodingInterview\Application\Usecase\Order\RebalanceOrderUserNotFoundException; use Folio\CodingInterview\Application\Usecase\Order\RebalanceOrderUsecase; use Folio\CodingInterview\Application\Usecase\Order\RebalanceOrderUsecaseInput; -final class NewContributionOrderRequest +final class NewOrderRequest { public function __construct( public readonly string $userId, @@ -24,7 +24,7 @@ public function __construct( ) {} } -final class AdditionalContributionOrderRequest +final class AdditionalOrderRequest { public function __construct( public readonly string $userId, @@ -42,25 +42,25 @@ final class OrderController use PresentationPreparation; public function __construct( - private readonly NewContributionOrderUsecase $newContributionOrderUsecase, + private readonly NewOrderUsecase $newOrderUsecase, private readonly AdditionalBuyOrderUsecase $additionalBuyOrderUsecase, private readonly RebalanceOrderUsecase $rebalanceOrderUsecase, ) {} - public function newContributionOrder(NewContributionOrderRequest $req): void + public function newOrder(NewOrderRequest $req): void { $uid = $this->parseUserId($req->userId); $amt = $this->parseAmount($req->amount); try { - $this->newContributionOrderUsecase->run(new NewContributionOrderUsecaseInput($uid, $amt)); - } catch (NewContributionOrderUserAlreadyExistsException $e) { + $this->newOrderUsecase->run(new NewOrderUsecaseInput($uid, $amt)); + } catch (NewOrderUserAlreadyExistsException $e) { throw new BadRequestException('user already has account'); - } catch (NewContributionOrderAmountTooSmallException $e) { + } catch (NewOrderAmountTooSmallException $e) { throw new BadRequestException('amount is too small'); } } - public function additionalContributionOrder(AdditionalContributionOrderRequest $req): void + public function additionalOrder(AdditionalOrderRequest $req): void { $uid = $this->parseUserId($req->userId); $amt = $this->parseAmount($req->amount); diff --git a/php/tests/OrderScenarioTest.php b/php/tests/OrderScenarioTest.php index 369795b..ce7f18b 100644 --- a/php/tests/OrderScenarioTest.php +++ b/php/tests/OrderScenarioTest.php @@ -6,14 +6,12 @@ use Folio\CodingInterview\Domain\BigDecimal; use Folio\CodingInterview\Infrastructure\Server\DummyServer; -use Folio\CodingInterview\Presentation\AdditionalContributionOrderRequest; +use Folio\CodingInterview\Presentation\AdditionalOrderRequest; use Folio\CodingInterview\Presentation\BadRequestException; use Folio\CodingInterview\Presentation\GetAssetRequest; -use Folio\CodingInterview\Presentation\MarketPriceItemDto; -use Folio\CodingInterview\Presentation\NewContributionOrderRequest; +use Folio\CodingInterview\Presentation\NewOrderRequest; use Folio\CodingInterview\Presentation\PortfolioItemDto; use Folio\CodingInterview\Presentation\RebalanceOrderRequest; -use Folio\CodingInterview\Presentation\UpdateMarketPriceRequest; use Folio\CodingInterview\Presentation\UpdateOptimalPortfolioRequest; use PHPUnit\Framework\TestCase; @@ -25,17 +23,12 @@ public function test新規拠出追加拠出リバランスの一連の操作が $ac = $server->assetController; $pc = $server->portfolioController; $oc = $server->orderController; - $mp = $server->marketPriceController; - // initialize market price and optimal portfolio + // initialize 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)); @@ -53,8 +46,8 @@ public function test新規拠出追加拠出リバランスの一連の操作が new PortfolioItemDto('Somy', '0.60'), ])); - // And: 新規拠出を 100,000 円で注文する - $oc->newContributionOrder(new NewContributionOrderRequest($userId, '100000')); + // And: 新規注文を 100,000 円で注文する + $oc->newOrder(new NewOrderRequest($userId, '100000')); $asset1 = $ac->getAsset(new GetAssetRequest($userId)); $symbols = array_map(fn($e) => $e->symbol, $asset1->stocks); @@ -63,33 +56,33 @@ public function test新規拠出追加拠出リバランスの一連の操作が $total1 = new BigDecimal($asset1->cashAmount); foreach ($asset1->stocks as $e) { - $total1 = $total1->add(new BigDecimal($e->evaluationAmount)); + $total1 = $total1->add(new BigDecimal($e->amountJpy)); } $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('38000', (string)(new BigDecimal($toyopa1->amountJpy))); + $this->assertSame('57000', (string)(new BigDecimal($somy1->amountJpy))); $this->assertSame('5000', (string)(new BigDecimal($asset1->cashAmount))); - // When: 追加拠出を 100,000 円で注文する - $oc->additionalContributionOrder(new AdditionalContributionOrderRequest($userId, '100000')); + // When: 追加注文を 100,000 円で注文する + $oc->additionalOrder(new AdditionalOrderRequest($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)); + $total2 = $total2->add(new BigDecimal($e->amountJpy)); } $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('76000', (string)(new BigDecimal($toyopa2->amountJpy))); + $this->assertSame('114000', (string)(new BigDecimal($somy2->amountJpy))); $this->assertSame('10000', (string)(new BigDecimal($asset2->cashAmount))); // When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする @@ -103,15 +96,15 @@ public function test新規拠出追加拠出リバランスの一連の操作が $asset3 = $ac->getAsset(new GetAssetRequest($userId)); $total3 = new BigDecimal($asset3->cashAmount); foreach ($asset3->stocks as $e) { - $total3 = $total3->add(new BigDecimal($e->evaluationAmount)); + $total3 = $total3->add(new BigDecimal($e->amountJpy)); } $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('19000', (string)(new BigDecimal($toyopa3->amountJpy))); + $this->assertSame('171000', (string)(new BigDecimal($somy3->amountJpy))); $this->assertSame('10000', (string)(new BigDecimal($asset3->cashAmount))); } From 48b556f1ee6a8829e3c354fe5e1996a1d040cdc6 Mon Sep 17 00:00:00 2001 From: krrrr38 Date: Sat, 20 Jun 2026 05:31:51 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor(python):=20=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=81=AE=E5=89=8A=E9=99=A4=E3=81=A8=E3=83=89?= =?UTF-8?q?=E3=83=A1=E3=82=A4=E3=83=B3=E3=83=A2=E3=83=87=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=81=AB=E3=82=88=E3=82=8B=E7=B0=A1=E7=95=A5?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: krrrr38 --- python/README.md | 35 ++++---- .../repository/account_repository.py | 2 +- .../repository/market_price_repository.py | 13 --- .../application/service/asset_service.py | 21 ----- .../application/service/portfolio_service.py | 83 ------------------- .../usecase/asset/get_asset_usecase.py | 14 +--- .../usecase/market_price/__init__.py | 0 .../update_market_price_usecase.py | 27 ------ .../order/additional_buy_order_usecase.py | 7 +- ..._order_usecase.py => new_order_usecase.py} | 14 ++-- .../usecase/order/rebalance_order_usecase.py | 7 +- python/src/coding_interview/domain/account.py | 79 ++++++++++++++++++ .../src/coding_interview/domain/constants.py | 5 -- python/src/coding_interview/domain/stock.py | 11 +-- .../coding_interview/domain/stock_symbol.py | 2 + python/src/coding_interview/domain/user_id.py | 2 + .../repository/account_repository_impl.py | 2 +- .../market_price_repository_impl.py | 17 ---- .../infrastructure/server/dummy_server.py | 23 ++--- .../presentation/asset_controller.py | 4 +- .../presentation/market_price_controller.py | 41 --------- .../presentation/order_controller.py | 20 ++--- python/tests/test_order_scenario.py | 52 ++++++------ 23 files changed, 158 insertions(+), 323 deletions(-) delete mode 100644 python/src/coding_interview/application/repository/market_price_repository.py delete mode 100644 python/src/coding_interview/application/service/asset_service.py delete mode 100644 python/src/coding_interview/application/service/portfolio_service.py delete mode 100644 python/src/coding_interview/application/usecase/market_price/__init__.py delete mode 100644 python/src/coding_interview/application/usecase/market_price/update_market_price_usecase.py rename python/src/coding_interview/application/usecase/order/{new_contribution_order_usecase.py => new_order_usecase.py} (67%) create mode 100644 python/src/coding_interview/domain/account.py delete mode 100644 python/src/coding_interview/infrastructure/repository/market_price_repository_impl.py delete mode 100644 python/src/coding_interview/presentation/market_price_controller.py diff --git a/python/README.md b/python/README.md index d65f8b4..ff14daa 100644 --- a/python/README.md +++ b/python/README.md @@ -23,33 +23,32 @@ pytest -v Repository はモック実装としてin-memoryにデータを保持していますが、RDBを使う想定で回答してください。 -### 株と評価額 +### 銘柄と保有額 -- 株には株数(qty)があります(例: 1株、2株) -- 株には1株あたりの市場価格があります(例: 1株あたり100円) - - 例: 顧客が5株保有している場合、評価額はこの時点では `5株 × 100円 = 500円` となります +- 顧客は銘柄ごとに保有額(円)を保持します(例: A銘柄を 500 円分保有する) + - 簡略化のため、株数や市場価格は扱わず、各銘柄を金額(円)で直接保有するものとします ### ロボアドバイザーサービス - **顧客の口座** - - 新規拠出を行うと、口座がすぐに開きます + - 新規注文を行うと、口座がすぐに開きます - 口座の中で資産を管理することになります - **顧客の資産** - - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します - - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円分をいくつかの株で保有する - - 株は価格で保持するのではなく、株数で保持します - - そのため、市場価格に応じて評価額は変わることになります + - 顧客は現金と銘柄を保有し、総資産の5%は常に現金で保持します + - 例: 総資産105万円のうち5万円を現金として保持し、残り100万円分をいくつかの銘柄で保有する + - 各銘柄毎の資産は金額(円)で保持します - **最適ポートフォリオ** - - サービスが管理する、株の評価額ベースの構成比率 - - 例: A株を30%・B株を70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30%分の株数 + B株95万円*70%分の株数 になるように努める - - 購入時・売却時・リバランス時には、売買後の資産比率が __現在の最適ポートフォリオ__ に近づける形での売買を実施します -- **株の売買** - - 本アプリケーションでは、注文APIを叩くと __即時__ 株の売買が成立し資産に反映出来るものとします + - サービスが管理する、銘柄の保有額ベースの構成比率(現金は含めない) + - 例: A銘柄を30%・B銘柄を70%で保有する場合、総資産105万円のうち 5万円の現金 + A銘柄30万円分 + B銘柄70万円分 になるように努める + - 購入時・売却時・リバランス時には、注文後の資産比率が __現在の最適ポートフォリオ__ に近づける形での調整を実施します +- **資産の調整** + - 本アプリケーションでは、注文APIを叩くと __即時__ 売買が成立し資産に反映出来るものとします - 用語 - - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 - - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 - - 全売却注文: 運用中の株を全て売却すること。 - - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + - 新規注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加注文: 追加で資金を投入すること。この注文を入れると、運用する金額が増える。 + - 全売却注文: 運用中の銘柄を全て売却すること。 + - リバランス注文: 運用されている資産を、サービスで保有する最適ポートフォリオに近づけるよう調整すること。最適ポートフォリオの比率が変更された場合に、その比率へ寄せる。 + - 例: 最適ポートフォリオを `A銘柄30%+B銘柄70%` から `A銘柄50%+B銘柄50%` に変えてからリバランス注文をすると、顧客の口座も最新の最適ポートフォリオ通りの内容になる ## 確認観点 diff --git a/python/src/coding_interview/application/repository/account_repository.py b/python/src/coding_interview/application/repository/account_repository.py index fd9d10a..f788c9f 100644 --- a/python/src/coding_interview/application/repository/account_repository.py +++ b/python/src/coding_interview/application/repository/account_repository.py @@ -2,7 +2,7 @@ from typing import Protocol -from coding_interview.domain.stock import Account +from coding_interview.domain.account import Account from coding_interview.domain.user_id import UserId diff --git a/python/src/coding_interview/application/repository/market_price_repository.py b/python/src/coding_interview/application/repository/market_price_repository.py deleted file mode 100644 index 6b28b90..0000000 --- a/python/src/coding_interview/application/repository/market_price_repository.py +++ /dev/null @@ -1,13 +0,0 @@ -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/service/asset_service.py b/python/src/coding_interview/application/service/asset_service.py deleted file mode 100644 index 2e13a5f..0000000 --- a/python/src/coding_interview/application/service/asset_service.py +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index dc8b13d..0000000 --- a/python/src/coding_interview/application/service/portfolio_service.py +++ /dev/null @@ -1,83 +0,0 @@ -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/asset/get_asset_usecase.py b/python/src/coding_interview/application/usecase/asset/get_asset_usecase.py index ad10f05..a59e5ac 100644 --- a/python/src/coding_interview/application/usecase/asset/get_asset_usecase.py +++ b/python/src/coding_interview/application/usecase/asset/get_asset_usecase.py @@ -4,8 +4,6 @@ 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 @@ -19,7 +17,7 @@ class GetAssetUsecaseInput: @dataclass(frozen=True) class GetAssetStockOutput: symbol: StockSymbol - evaluation_amount: Decimal + amount_jpy: Decimal @dataclass(frozen=True) @@ -29,21 +27,15 @@ class GetAssetUsecaseOutput: class GetAssetUsecase: - def __init__( - self, - account_repository: AccountRepository, - market_price_repository: MarketPriceRepository, - ) -> None: + def __init__(self, account_repository: AccountRepository) -> 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)) + GetAssetStockOutput(symbol=s.symbol, amount_jpy=s.amount_jpy) for s in account.stocks ) return GetAssetUsecaseOutput(cash_amount=account.cash, stocks=stocks) diff --git a/python/src/coding_interview/application/usecase/market_price/__init__.py b/python/src/coding_interview/application/usecase/market_price/__init__.py deleted file mode 100644 index e69de29..0000000 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 deleted file mode 100644 index d676215..0000000 --- a/python/src/coding_interview/application/usecase/market_price/update_market_price_usecase.py +++ /dev/null @@ -1,27 +0,0 @@ -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/additional_buy_order_usecase.py b/python/src/coding_interview/application/usecase/order/additional_buy_order_usecase.py index b4acbdd..f494139 100644 --- 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 @@ -4,9 +4,7 @@ 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 @@ -23,11 +21,9 @@ 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: @@ -36,6 +32,5 @@ def run(self, input: AdditionalBuyOrderUsecaseInput) -> None: 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) + new_account = account.add_funds(input.amount, portfolio) 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_order_usecase.py similarity index 67% rename from python/src/coding_interview/application/usecase/order/new_contribution_order_usecase.py rename to python/src/coding_interview/application/usecase/order/new_order_usecase.py index ced8afd..2330828 100644 --- a/python/src/coding_interview/application/usecase/order/new_contribution_order_usecase.py +++ b/python/src/coding_interview/application/usecase/order/new_order_usecase.py @@ -4,37 +4,33 @@ 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.account import Account from coding_interview.domain.constants import MIN_OPERATION_AMOUNT from coding_interview.domain.user_id import UserId @dataclass(frozen=True) -class NewContributionOrderUsecaseInput: +class NewOrderUsecaseInput: user_id: UserId amount: Decimal -class NewContributionOrderUsecase: +class NewOrderUsecase: 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: + def run(self, input: NewOrderUsecaseInput) -> 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) + account = Account.open_account(input.amount, portfolio) 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 index fd2b922..7399ed6 100644 --- a/python/src/coding_interview/application/usecase/order/rebalance_order_usecase.py +++ b/python/src/coding_interview/application/usecase/order/rebalance_order_usecase.py @@ -3,9 +3,7 @@ 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 @@ -20,17 +18,14 @@ 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) + new_account = account.rebalance(portfolio) self._account_repository.upsert(input.user_id, new_account) diff --git a/python/src/coding_interview/domain/account.py b/python/src/coding_interview/domain/account.py new file mode 100644 index 0000000..bef2d40 --- /dev/null +++ b/python/src/coding_interview/domain/account.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, ROUND_DOWN + +from coding_interview.domain.stock import Portfolio, Stock +from coding_interview.domain.stock_symbol import StockSymbol + +_ZERO_DP = Decimal("1") + + +def _floor0(x: Decimal) -> Decimal: + """円未満を切り捨てる(資産配分はすべて円単位で行う)。""" + return x.quantize(_ZERO_DP, rounding=ROUND_DOWN) + + +@dataclass(frozen=True) +class Account: + """口座を表す。""" + + cash: Decimal + stocks: tuple[Stock, ...] + + def total(self) -> Decimal: + """口座の総資産(現金 + 各銘柄の保有額)を返す。""" + return self.cash + sum(s.amount_jpy for s in self.stocks) + + @classmethod + def open_account(cls, amount: Decimal, portfolio: Portfolio) -> "Account": + """新規注文額を、最適ポートフォリオに沿って配分した口座を生成する。""" + from coding_interview.domain.constants import CASH_RATE + + cash_from_rate = _floor0(amount * CASH_RATE) + investable = amount - cash_from_rate + stocks: list[Stock] = [] + used_for_stocks = Decimal(0) + for item in portfolio.items: + amt = _floor0(investable * item.rate) + stocks.append(Stock(item.symbol, amt)) + used_for_stocks += amt + residual = investable - used_for_stocks + return cls(cash=cash_from_rate + residual, stocks=tuple(stocks)) + + def add_funds(self, amount: Decimal, portfolio: Portfolio) -> "Account": + """追加注文額を口座へ反映する。最適ポートフォリオの目標額を下回らない範囲で + 既存の保有額を維持し、ポートフォリオ外の銘柄はそのまま保持する。""" + from coding_interview.domain.constants import CASH_RATE + + total_after = self.total() + amount + target_cash = _floor0(total_after * CASH_RATE) + investable = total_after - target_cash + + current_amount: dict[StockSymbol, Decimal] = {s.symbol: s.amount_jpy for s in self.stocks} + portfolio_symbols = {item.symbol for item in portfolio.items} + + new_portfolio_stocks: list[Stock] = [] + for item in portfolio.items: + target = _floor0(investable * item.rate) + current = current_amount.get(item.symbol, Decimal(0)) + final = current if current > target else target + new_portfolio_stocks.append(Stock(item.symbol, final)) + + preserved_stocks = [s for s in self.stocks if s.symbol not in portfolio_symbols] + all_stocks = tuple(new_portfolio_stocks + preserved_stocks) + final_amount = sum(s.amount_jpy for s in all_stocks) + return Account(cash=total_after - final_amount, stocks=all_stocks) + + def rebalance(self, portfolio: Portfolio) -> "Account": + """保有資産を最適ポートフォリオの比率に近づける。""" + # XXX this implementation might not be correct + investable = self.total() + new_stocks: list[Stock] = [] + used_for_stocks = Decimal(0) + for item in portfolio.items: + amt = _floor0(investable * item.rate) + new_stocks.append(Stock(item.symbol, amt)) + used_for_stocks += amt + final_cash = investable - used_for_stocks + return Account(cash=final_cash, stocks=tuple(new_stocks)) diff --git a/python/src/coding_interview/domain/constants.py b/python/src/coding_interview/domain/constants.py index 77bf826..af5a82f 100644 --- a/python/src/coding_interview/domain/constants.py +++ b/python/src/coding_interview/domain/constants.py @@ -10,11 +10,6 @@ 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")), diff --git a/python/src/coding_interview/domain/stock.py b/python/src/coding_interview/domain/stock.py index a29e9be..5c7c3d6 100644 --- a/python/src/coding_interview/domain/stock.py +++ b/python/src/coding_interview/domain/stock.py @@ -6,18 +6,21 @@ from coding_interview.domain.stock_symbol import StockSymbol +# Stock は保有銘柄(銘柄と保有額)を表す。 @dataclass(frozen=True) class Stock: symbol: StockSymbol - qty: Decimal + amount_jpy: Decimal +# PortfolioItem はポートフォリオの銘柄ごとの構成比率を表す。 @dataclass(frozen=True) class PortfolioItem: symbol: StockSymbol rate: Decimal +# Portfolio は最適ポートフォリオ(銘柄ごとの構成比率)を表す。 @dataclass(frozen=True) class Portfolio: items: tuple[PortfolioItem, ...] @@ -31,9 +34,3 @@ def __post_init__(self) -> None: 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 index 093bba0..5dc943f 100644 --- a/python/src/coding_interview/domain/stock_symbol.py +++ b/python/src/coding_interview/domain/stock_symbol.py @@ -4,6 +4,8 @@ class StockSymbol(Enum): + """銘柄を表す。""" + Toyopa = "Toyopa" Somy = "Somy" diff --git a/python/src/coding_interview/domain/user_id.py b/python/src/coding_interview/domain/user_id.py index 40fdd0a..657739a 100644 --- a/python/src/coding_interview/domain/user_id.py +++ b/python/src/coding_interview/domain/user_id.py @@ -5,6 +5,8 @@ @dataclass(frozen=True) class UserId: + """ユーザーIDを表す。""" + value: str def __post_init__(self) -> None: diff --git a/python/src/coding_interview/infrastructure/repository/account_repository_impl.py b/python/src/coding_interview/infrastructure/repository/account_repository_impl.py index 9f34a29..ec6c196 100644 --- a/python/src/coding_interview/infrastructure/repository/account_repository_impl.py +++ b/python/src/coding_interview/infrastructure/repository/account_repository_impl.py @@ -1,6 +1,6 @@ from __future__ import annotations -from coding_interview.domain.stock import Account +from coding_interview.domain.account import Account from coding_interview.domain.user_id import UserId 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 deleted file mode 100644 index 8f67fb8..0000000 --- a/python/src/coding_interview/infrastructure/repository/market_price_repository_impl.py +++ /dev/null @@ -1,17 +0,0 @@ -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/server/dummy_server.py b/python/src/coding_interview/infrastructure/server/dummy_server.py index a8c05b0..999b67f 100644 --- a/python/src/coding_interview/infrastructure/server/dummy_server.py +++ b/python/src/coding_interview/infrastructure/server/dummy_server.py @@ -3,17 +3,14 @@ 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.new_order_usecase import NewOrderUsecase 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 @@ -23,27 +20,20 @@ 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_asset_usecase = GetAssetUsecase(account_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 - ) + new_order_usecase = NewOrderUsecase(account_repository, portfolio_repository) additional_buy_order_usecase = AdditionalBuyOrderUsecase( - account_repository, portfolio_repository, market_price_repository - ) - rebalance_order_usecase = RebalanceOrderUsecase( - account_repository, portfolio_repository, market_price_repository + account_repository, portfolio_repository ) + rebalance_order_usecase = RebalanceOrderUsecase(account_repository, portfolio_repository) return cls( asset_controller=AssetController(get_asset_usecase), @@ -51,9 +41,8 @@ def default(cls) -> "DummyServer": get_latest_portfolio_usecase, update_portfolio_usecase ), order_controller=OrderController( - new_contribution_order_usecase, + new_order_usecase, additional_buy_order_usecase, rebalance_order_usecase, ), - market_price_controller=MarketPriceController(update_market_price_usecase), ) diff --git a/python/src/coding_interview/presentation/asset_controller.py b/python/src/coding_interview/presentation/asset_controller.py index 4a30bda..35ee56a 100644 --- a/python/src/coding_interview/presentation/asset_controller.py +++ b/python/src/coding_interview/presentation/asset_controller.py @@ -14,7 +14,7 @@ @dataclass(frozen=True) class StockDto: symbol: str - evaluationAmount: str + amountJpy: str @dataclass(frozen=True) @@ -40,5 +40,5 @@ def get_asset(self, req: GetAssetRequest) -> GetAssetResponse: 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), + stocks=tuple(StockDto(symbol=str(s.symbol), amountJpy=str(s.amount_jpy)) for s in out.stocks), ) diff --git a/python/src/coding_interview/presentation/market_price_controller.py b/python/src/coding_interview/presentation/market_price_controller.py deleted file mode 100644 index 7a21b1e..0000000 --- a/python/src/coding_interview/presentation/market_price_controller.py +++ /dev/null @@ -1,41 +0,0 @@ -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 index 19600d1..3131ff0 100644 --- a/python/src/coding_interview/presentation/order_controller.py +++ b/python/src/coding_interview/presentation/order_controller.py @@ -11,9 +11,9 @@ AdditionalBuyOrderUsecase, AdditionalBuyOrderUsecaseInput, ) -from coding_interview.application.usecase.order.new_contribution_order_usecase import ( - NewContributionOrderUsecase, - NewContributionOrderUsecaseInput, +from coding_interview.application.usecase.order.new_order_usecase import ( + NewOrderUsecase, + NewOrderUsecaseInput, ) from coding_interview.application.usecase.order.rebalance_order_usecase import ( RebalanceOrderUsecase, @@ -24,13 +24,13 @@ @dataclass(frozen=True) -class NewContributionOrderRequest: +class NewOrderRequest: userId: str amount: str @dataclass(frozen=True) -class AdditionalContributionOrderRequest: +class AdditionalOrderRequest: userId: str amount: str @@ -43,25 +43,25 @@ class RebalanceOrderRequest: class OrderController: def __init__( self, - new_contribution_order_usecase: NewContributionOrderUsecase, + new_order_usecase: NewOrderUsecase, additional_buy_order_usecase: AdditionalBuyOrderUsecase, rebalance_order_usecase: RebalanceOrderUsecase, ) -> None: - self._new_contribution_order_usecase = new_contribution_order_usecase + self._new_order_usecase = new_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: + def new_order(self, req: NewOrderRequest) -> None: uid = parse_user_id(req.userId) amt = parse_amount(req.amount) try: - self._new_contribution_order_usecase.run(NewContributionOrderUsecaseInput(uid, amt)) + self._new_order_usecase.run(NewOrderUsecaseInput(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: + def additional_order(self, req: AdditionalOrderRequest) -> None: uid = parse_user_id(req.userId) amt = parse_amount(req.amount) try: diff --git a/python/tests/test_order_scenario.py b/python/tests/test_order_scenario.py index 7d703ba..f2d41cd 100644 --- a/python/tests/test_order_scenario.py +++ b/python/tests/test_order_scenario.py @@ -6,10 +6,9 @@ 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, + AdditionalOrderRequest, + NewOrderRequest, RebalanceOrderRequest, ) from coding_interview.presentation.portfolio_controller import PortfolioItemDto, UpdateOptimalPortfolioRequest @@ -18,17 +17,11 @@ @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 @@ -51,35 +44,37 @@ def test_investment_operation_scenario(server: DummyServer) -> None: ) ) - # 新規拠出を 100,000 円で注文する - oc.new_contribution_order(NewContributionOrderRequest(user_id, "100000")) + # 新規注文を 100,000 円で行う + oc.new_order(NewOrderRequest(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) + total1 = Decimal(asset1.cashAmount) + sum(Decimal(s.amountJpy) for s in asset1.stocks) assert abs(total1 - Decimal("100000")) <= Decimal("2") - # 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる + # 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の保有額が 38,000 円(40%)、Somy の保有額が 57,000 円(60%) となる + # cash = floor0(100000 * 0.05) = 5000, investable = 100000 - 5000 = 95000 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") + assert Decimal(asset1_toyopa.amountJpy) == Decimal("38000") # floor0(95000 * 0.40) = 38000 + assert Decimal(asset1_somy.amountJpy) == Decimal("57000") # floor0(95000 * 0.60) = 57000 + assert Decimal(asset1.cashAmount) == Decimal("5000") # 100000 - 38000 - 57000 - # 追加拠出を 100,000 円で注文する - oc.additional_contribution_order(AdditionalContributionOrderRequest(user_id, "100000")) + # 追加注文を 100,000 円で行う + oc.additional_order(AdditionalOrderRequest(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) + total2 = Decimal(asset2.cashAmount) + sum(Decimal(s.amountJpy) for s in asset2.stocks) assert abs(total2 - Decimal("200000")) <= Decimal("4") - # 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる + # 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の保有額が 76,000 円(40%)、Somy の保有額が 114,000 円(60%) となる + # totalAfter = 200000; investable = 200000 - floor0(200000 * 0.05) = 190000 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") + assert Decimal(asset2_toyopa.amountJpy) == Decimal("76000") # floor0(190000 * 0.40) = 76000 + assert Decimal(asset2_somy.amountJpy) == Decimal("114000") # floor0(190000 * 0.60) = 114000 + assert Decimal(asset2.cashAmount) == Decimal("10000") # 200000 - 76000 - 114000 # 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする pc.update_optimal_portfolio( @@ -91,12 +86,13 @@ def test_investment_operation_scenario(server: DummyServer) -> None: # リバランス後も資産合計がほぼ変わらない asset3 = ac.get_asset(GetAssetRequest(user_id)) - total3 = Decimal(asset3.cashAmount) + sum(Decimal(s.evaluationAmount) for s in asset3.stocks) + total3 = Decimal(asset3.cashAmount) + sum(Decimal(s.amountJpy) for s in asset3.stocks) assert abs(total3 - total2) <= Decimal("4") - # 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる + # 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の保有額が 19,000 円(10%)、Somy の保有額が 171,000 円(90%) となる + # total = 200000; investable = 200000 - floor0(200000 * 0.05) = 190000 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") + assert Decimal(asset3_toyopa.amountJpy) == Decimal("19000") # floor0(190000 * 0.10) = 19000 + assert Decimal(asset3_somy.amountJpy) == Decimal("171000") # floor0(190000 * 0.90) = 171000 + assert Decimal(asset3.cashAmount) == Decimal("10000") # 200000 - 19000 - 171000 From 8cf64ef3ccd1274f8e0caabcbe39a3f08d556c28 Mon Sep 17 00:00:00 2001 From: krrrr38 Date: Sat, 20 Jun 2026 05:31:55 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor(ruby):=20=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=81=AE=E5=89=8A=E9=99=A4=E3=81=A8=E3=83=89?= =?UTF-8?q?=E3=83=A1=E3=82=A4=E3=83=B3=E3=83=A2=E3=83=87=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=81=AB=E3=82=88=E3=82=8B=E7=B0=A1=E7=95=A5?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: krrrr38 --- ruby/README.md | 35 ++++----- .../repository/market_price_repository.rb | 11 --- .../application/service/asset_service.rb | 21 ----- .../application/service/portfolio_service.rb | 74 ------------------ .../usecase/asset/get_asset_usecase.rb | 10 +-- .../update_market_price_usecase.rb | 21 ----- .../order/additional_buy_order_usecase.rb | 7 +- .../order/new_contribution_order_usecase.rb | 34 -------- .../usecase/order/new_order_usecase.rb | 32 ++++++++ .../usecase/order/rebalance_order_usecase.rb | 8 +- .../coding_interview/domain/app_constants.rb | 5 -- ruby/lib/coding_interview/domain/stock.rb | 77 ++++++++++++++++++- .../coding_interview/domain/stock_symbol.rb | 1 + ruby/lib/coding_interview/domain/user_id.rb | 1 + .../market_price_repository_impl.rb | 24 ------ .../infrastructure/server/dummy_server.rb | 25 +++--- .../presentation/asset_controller.rb | 4 +- .../presentation/market_price_controller.rb | 34 -------- .../presentation/order_controller.rb | 22 +++--- ruby/spec/order_scenario_spec.rb | 45 +++++------ 20 files changed, 174 insertions(+), 317 deletions(-) delete mode 100644 ruby/lib/coding_interview/application/repository/market_price_repository.rb delete mode 100644 ruby/lib/coding_interview/application/service/asset_service.rb delete mode 100644 ruby/lib/coding_interview/application/service/portfolio_service.rb delete mode 100644 ruby/lib/coding_interview/application/usecase/market_price/update_market_price_usecase.rb delete mode 100644 ruby/lib/coding_interview/application/usecase/order/new_contribution_order_usecase.rb create mode 100644 ruby/lib/coding_interview/application/usecase/order/new_order_usecase.rb delete mode 100644 ruby/lib/coding_interview/infrastructure/repository/market_price_repository_impl.rb delete mode 100644 ruby/lib/coding_interview/presentation/market_price_controller.rb diff --git a/ruby/README.md b/ruby/README.md index 19fc02d..d43ea8a 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -21,33 +21,32 @@ bundle exec rspec Repository はモック実装としてin-memoryにデータを保持していますが、RDBを使う想定で回答してください。 -### 株と評価額 +### 銘柄と保有額 -- 株には株数(qty)があります(例: 1株、2株) -- 株には1株あたりの市場価格があります(例: 1株あたり100円) - - 例: 顧客が5株保有している場合、評価額はこの時点では `5株 × 100円 = 500円` となります +- 顧客は銘柄ごとに保有額(円)を保持します(例: A銘柄を 500 円分保有する) + - 簡略化のため、株数や市場価格は扱わず、各銘柄を金額(円)で直接保有するものとします ### ロボアドバイザーサービス - **顧客の口座** - - 新規拠出を行うと、口座がすぐに開きます + - 新規注文を行うと、口座がすぐに開きます - 口座の中で資産を管理することになります - **顧客の資産** - - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します - - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円分をいくつかの株で保有する - - 株は価格で保持するのではなく、株数で保持します - - そのため、市場価格に応じて評価額は変わることになります + - 顧客は現金と銘柄を保有し、総資産の5%は常に現金で保持します + - 例: 総資産105万円のうち5万円を現金として保持し、残り100万円分をいくつかの銘柄で保有する + - 各銘柄毎の資産は金額(円)で保持します - **最適ポートフォリオ** - - サービスが管理する、株の評価額ベースの構成比率 - - 例: A株を30%・B株を70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30%分の株数 + B株95万円*70%分の株数 になるように努める - - 購入時・売却時・リバランス時には、売買後の資産比率が __現在の最適ポートフォリオ__ に近づける形での売買を実施します -- **株の売買** - - 本アプリケーションでは、注文APIを叩くと __即時__ 株の売買が成立し資産に反映出来るものとします + - サービスが管理する、銘柄の保有額ベースの構成比率(現金は含めない) + - 例: A銘柄を30%・B銘柄を70%で保有する場合、総資産105万円のうち 5万円の現金 + A銘柄30万円分 + B銘柄70万円分 になるように努める + - 購入時・売却時・リバランス時には、注文後の資産比率が __現在の最適ポートフォリオ__ に近づける形での調整を実施します +- **資産の調整** + - 本アプリケーションでは、注文APIを叩くと __即時__ 売買が成立し資産に反映出来るものとします - 用語 - - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 - - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 - - 全売却注文: 運用中の株を全て売却すること。 - - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + - 新規注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加注文: 追加で資金を投入すること。この注文を入れると、運用する金額が増える。 + - 全売却注文: 運用中の銘柄を全て売却すること。 + - リバランス注文: 運用されている資産を、サービスで保有する最適ポートフォリオに近づけるよう調整すること。最適ポートフォリオの比率が変更された場合に、その比率へ寄せる。 + - 例: 最適ポートフォリオを `A銘柄30%+B銘柄70%` から `A銘柄50%+B銘柄50%` に変えてからリバランス注文をすると、顧客の口座も最新の最適ポートフォリオ通りの内容になる ## 確認観点 diff --git a/ruby/lib/coding_interview/application/repository/market_price_repository.rb b/ruby/lib/coding_interview/application/repository/market_price_repository.rb deleted file mode 100644 index 708121e..0000000 --- a/ruby/lib/coding_interview/application/repository/market_price_repository.rb +++ /dev/null @@ -1,11 +0,0 @@ -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/service/asset_service.rb b/ruby/lib/coding_interview/application/service/asset_service.rb deleted file mode 100644 index a0f0d87..0000000 --- a/ruby/lib/coding_interview/application/service/asset_service.rb +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index d3d9ef2..0000000 --- a/ruby/lib/coding_interview/application/service/portfolio_service.rb +++ /dev/null @@ -1,74 +0,0 @@ -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 index b8696cb..fd10177 100644 --- a/ruby/lib/coding_interview/application/usecase/asset/get_asset_usecase.rb +++ b/ruby/lib/coding_interview/application/usecase/asset/get_asset_usecase.rb @@ -1,28 +1,24 @@ -require_relative "../../service/asset_service" - module CodingInterview module Application module Usecase module Asset GetAssetUsecaseInput = Struct.new(:user_id) - GetAssetStockOutput = Struct.new(:symbol, :evaluation_amount) + GetAssetStockOutput = Struct.new(:symbol, :amount_jpy) GetAssetUsecaseOutput = Struct.new(:cash_amount, :stocks) class GetAssetUsecaseException < StandardError; end class UserNotFound < GetAssetUsecaseException; end class GetAssetUsecase - def initialize(account_repository, market_price_repository) + def initialize(account_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)) + GetAssetStockOutput.new(e.symbol, e.amount_jpy) end GetAssetUsecaseOutput.new(account.cash, stocks) 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 deleted file mode 100644 index 1c0e706..0000000 --- a/ruby/lib/coding_interview/application/usecase/market_price/update_market_price_usecase.rb +++ /dev/null @@ -1,21 +0,0 @@ -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 index 3a9a51a..dc7a54a 100644 --- 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 @@ -1,4 +1,3 @@ -require_relative "../../service/portfolio_service" require_relative "../../../domain/app_constants" module CodingInterview @@ -12,10 +11,9 @@ class AdditionalBuyUserNotFound < AdditionalBuyOrderUsecaseException; end class AdditionalBuyAmountTooSmall < AdditionalBuyOrderUsecaseException; end class AdditionalBuyOrderUsecase - def initialize(account_repository, portfolio_repository, market_price_repository) + def initialize(account_repository, portfolio_repository) @account_repository = account_repository @portfolio_repository = portfolio_repository - @market_price_repository = market_price_repository end def run(input) @@ -24,8 +22,7 @@ def run(input) raise AdditionalBuyUserNotFound if account.nil? portfolio = @portfolio_repository.get - prices = @market_price_repository.all - updated = Service::PortfolioService.allocate_additional(account, input.amount, portfolio, prices) + updated = account.add_funds(input.amount, portfolio) @account_repository.upsert(input.user_id, updated) 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 deleted file mode 100644 index 40d0ffa..0000000 --- a/ruby/lib/coding_interview/application/usecase/order/new_contribution_order_usecase.rb +++ /dev/null @@ -1,34 +0,0 @@ -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/new_order_usecase.rb b/ruby/lib/coding_interview/application/usecase/order/new_order_usecase.rb new file mode 100644 index 0000000..ecd99d4 --- /dev/null +++ b/ruby/lib/coding_interview/application/usecase/order/new_order_usecase.rb @@ -0,0 +1,32 @@ +require_relative "../../../domain/app_constants" +require_relative "../../../domain/stock" + +module CodingInterview + module Application + module Usecase + module Order + NewOrderUsecaseInput = Struct.new(:user_id, :amount) + + class NewOrderUsecaseException < StandardError; end + class NewOrderUserAlreadyExistsError < NewOrderUsecaseException; end + class NewOrderAmountTooSmallError < NewOrderUsecaseException; end + + class NewOrderUsecase + def initialize(account_repository, portfolio_repository) + @account_repository = account_repository + @portfolio_repository = portfolio_repository + end + + def run(input) + raise NewOrderAmountTooSmallError if input.amount < Domain::AppConstants::MIN_OPERATION_AMOUNT + raise NewOrderUserAlreadyExistsError if @account_repository.exists?(input.user_id) + + portfolio = @portfolio_repository.get + account = Domain::Account.open_account(input.amount, portfolio) + @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 index c1eb676..18931e8 100644 --- a/ruby/lib/coding_interview/application/usecase/order/rebalance_order_usecase.rb +++ b/ruby/lib/coding_interview/application/usecase/order/rebalance_order_usecase.rb @@ -1,5 +1,3 @@ -require_relative "../../service/portfolio_service" - module CodingInterview module Application module Usecase @@ -10,10 +8,9 @@ class RebalanceOrderUsecaseException < StandardError; end class RebalanceUserNotFound < RebalanceOrderUsecaseException; end class RebalanceOrderUsecase - def initialize(account_repository, portfolio_repository, market_price_repository) + def initialize(account_repository, portfolio_repository) @account_repository = account_repository @portfolio_repository = portfolio_repository - @market_price_repository = market_price_repository end def run(input) @@ -21,8 +18,7 @@ def run(input) raise RebalanceUserNotFound if account.nil? portfolio = @portfolio_repository.get - prices = @market_price_repository.all - updated = Service::PortfolioService.rebalance(account, portfolio, prices) + updated = account.rebalance(portfolio) @account_repository.upsert(input.user_id, updated) end end diff --git a/ruby/lib/coding_interview/domain/app_constants.rb b/ruby/lib/coding_interview/domain/app_constants.rb index 806af05..686df4c 100644 --- a/ruby/lib/coding_interview/domain/app_constants.rb +++ b/ruby/lib/coding_interview/domain/app_constants.rb @@ -10,11 +10,6 @@ module AppConstants 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")) diff --git a/ruby/lib/coding_interview/domain/stock.rb b/ruby/lib/coding_interview/domain/stock.rb index 81159e4..7afe510 100644 --- a/ruby/lib/coding_interview/domain/stock.rb +++ b/ruby/lib/coding_interview/domain/stock.rb @@ -1,8 +1,14 @@ +require "bigdecimal" + module CodingInterview module Domain - Stock = Struct.new(:symbol, :qty) + # Stock は保有銘柄(銘柄と保有額)を表す。 + Stock = Struct.new(:symbol, :amount_jpy) + + # PortfolioItem は最適ポートフォリオの1銘柄エントリー(銘柄と構成比率)を表す。 PortfolioItem = Struct.new(:symbol, :rate) + # Portfolio は最適ポートフォリオ(銘柄ごとの構成比率)を表す。 class Portfolio attr_reader :items @@ -16,6 +22,73 @@ def initialize(items) end end - Account = Struct.new(:cash, :stocks) + # Account は口座を表す。 + Account = Struct.new(:cash, :stocks) do + def self.floor0(x) + x.truncate(0) + end + + # total は口座の総資産(現金 + 各銘柄の保有額)を返す。 + def total + stocks.inject(cash) { |acc, s| acc + s.amount_jpy } + end + + # open_account は新規注文額を、最適ポートフォリオに沿って配分した口座を生成する。 + def self.open_account(amount, portfolio) + cash_from_rate = floor0(amount * AppConstants::CASH_RATE) + investable = amount - cash_from_rate + + used_for_stocks = BigDecimal("0") + new_stocks = portfolio.items.map do |item| + amt = floor0(investable * item.rate) + used_for_stocks += amt + Stock.new(item.symbol, amt) + end + + residual = investable - used_for_stocks + new(cash_from_rate + residual, new_stocks) + end + + # add_funds は追加注文額を口座へ反映する。最適ポートフォリオの目標額を下回らない範囲で + # 既存の保有額を維持し、ポートフォリオ外の銘柄はそのまま保持する。 + def add_funds(amount, portfolio) + total_after = total + amount + target_cash = Account.floor0(total_after * AppConstants::CASH_RATE) + investable = total_after - target_cash + + current_amount = stocks.each_with_object({}) { |e, h| h[e.symbol] = e.amount_jpy } + portfolio_symbols = portfolio.items.map(&:symbol) + + new_portfolio_stocks = portfolio.items.map do |item| + target = Account.floor0(investable * item.rate) + current = current_amount.fetch(item.symbol, BigDecimal("0")) + final = current > target ? current : target + Stock.new(item.symbol, final) + end + + preserved_stocks = stocks.reject { |e| portfolio_symbols.include?(e.symbol) } + all_stocks = new_portfolio_stocks + preserved_stocks + + final_amount = all_stocks.inject(BigDecimal("0")) { |acc, e| acc + e.amount_jpy } + final_cash = total_after - final_amount + Account.new(final_cash, all_stocks) + end + + # rebalance は保有資産を最適ポートフォリオの比率に近づける。 + def rebalance(portfolio) + # XXX this implementation might not be correct + investable = total + + used_for_stocks = BigDecimal("0") + new_stocks = portfolio.items.map do |item| + amt = Account.floor0(investable * item.rate) + used_for_stocks += amt + Stock.new(item.symbol, amt) + end + + final_cash = investable - used_for_stocks + Account.new(final_cash, new_stocks) + end + end end end diff --git a/ruby/lib/coding_interview/domain/stock_symbol.rb b/ruby/lib/coding_interview/domain/stock_symbol.rb index e1bd2c3..ee4f211 100644 --- a/ruby/lib/coding_interview/domain/stock_symbol.rb +++ b/ruby/lib/coding_interview/domain/stock_symbol.rb @@ -1,5 +1,6 @@ module CodingInterview module Domain + # 銘柄を表す。 module StockSymbol Toyopa = :Toyopa Somy = :Somy diff --git a/ruby/lib/coding_interview/domain/user_id.rb b/ruby/lib/coding_interview/domain/user_id.rb index 39dc1c8..b1539b9 100644 --- a/ruby/lib/coding_interview/domain/user_id.rb +++ b/ruby/lib/coding_interview/domain/user_id.rb @@ -1,5 +1,6 @@ module CodingInterview module Domain + # ユーザーIDを表す。 class UserId attr_reader :value 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 deleted file mode 100644 index 91f78d8..0000000 --- a/ruby/lib/coding_interview/infrastructure/repository/market_price_repository_impl.rb +++ /dev/null @@ -1,24 +0,0 @@ -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/server/dummy_server.rb b/ruby/lib/coding_interview/infrastructure/server/dummy_server.rb index 47f78fb..224875f 100644 --- a/ruby/lib/coding_interview/infrastructure/server/dummy_server.rb +++ b/ruby/lib/coding_interview/infrastructure/server/dummy_server.rb @@ -1,50 +1,43 @@ 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/new_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 + attr_reader :asset_controller, :portfolio_controller, :order_controller - def initialize(asset_controller, portfolio_controller, order_controller, market_price_controller) + def initialize(asset_controller, portfolio_controller, order_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_asset_usecase = Application::Usecase::Asset::GetAssetUsecase.new(account_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) + new_order_usecase = Application::Usecase::Order::NewOrderUsecase.new(account_repository, portfolio_repository) + additional_buy_order_usecase = Application::Usecase::Order::AdditionalBuyOrderUsecase.new(account_repository, portfolio_repository) + rebalance_order_usecase = Application::Usecase::Order::RebalanceOrderUsecase.new(account_repository, portfolio_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) + order_controller = Presentation::OrderController.new(new_order_usecase, additional_buy_order_usecase, rebalance_order_usecase) - new(asset_controller, portfolio_controller, order_controller, market_price_controller) + new(asset_controller, portfolio_controller, order_controller) end end end diff --git a/ruby/lib/coding_interview/presentation/asset_controller.rb b/ruby/lib/coding_interview/presentation/asset_controller.rb index 8ce375b..5915901 100644 --- a/ruby/lib/coding_interview/presentation/asset_controller.rb +++ b/ruby/lib/coding_interview/presentation/asset_controller.rb @@ -4,7 +4,7 @@ module CodingInterview module Presentation - StockDto = Struct.new(:symbol, :evaluation_amount) + StockDto = Struct.new(:symbol, :amount_jpy) GetAssetRequest = Struct.new(:user_id) GetAssetResponse = Struct.new(:cash_amount, :stocks) @@ -25,7 +25,7 @@ def get_asset(req) 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")) } + out.stocks.map { |e| StockDto.new(e.symbol.to_s, e.amount_jpy.to_s("F")) } ) end end diff --git a/ruby/lib/coding_interview/presentation/market_price_controller.rb b/ruby/lib/coding_interview/presentation/market_price_controller.rb deleted file mode 100644 index 1a5c254..0000000 --- a/ruby/lib/coding_interview/presentation/market_price_controller.rb +++ /dev/null @@ -1,34 +0,0 @@ -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 index 032bb1e..5bf71a9 100644 --- a/ruby/lib/coding_interview/presentation/order_controller.rb +++ b/ruby/lib/coding_interview/presentation/order_controller.rb @@ -1,37 +1,37 @@ require_relative "presentation_exception" require_relative "presentation_preparation" -require_relative "../application/usecase/order/new_contribution_order_usecase" +require_relative "../application/usecase/order/new_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) + NewOrderRequest = Struct.new(:user_id, :amount) + AdditionalOrderRequest = 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 + def initialize(new_order_usecase, additional_buy_order_usecase, rebalance_order_usecase) + @new_order_usecase = new_order_usecase @additional_buy_order_usecase = additional_buy_order_usecase @rebalance_order_usecase = rebalance_order_usecase end - def new_contribution_order(req) + def new_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) + @new_order_usecase.run( + Application::Usecase::Order::NewOrderUsecaseInput.new(uid, amt) ) - rescue Application::Usecase::Order::UserAlreadyExists + rescue Application::Usecase::Order::NewOrderUserAlreadyExistsError raise BadRequestException.new("user already has account") - rescue Application::Usecase::Order::NewContributionAmountTooSmall + rescue Application::Usecase::Order::NewOrderAmountTooSmallError raise BadRequestException.new("amount is too small") end - def additional_contribution_order(req) + def additional_order(req) uid = parse_user_id(req.user_id) amt = parse_amount(req.amount) @additional_buy_order_usecase.run( diff --git a/ruby/spec/order_scenario_spec.rb b/ruby/spec/order_scenario_spec.rb index d98533c..0ea2296 100644 --- a/ruby/spec/order_scenario_spec.rb +++ b/ruby/spec/order_scenario_spec.rb @@ -7,7 +7,6 @@ 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( @@ -16,15 +15,9 @@ 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 + it "新規注文・追加注文・リバランスの一連の操作が正しく機能する" do user_id = SecureRandom.uuid # Given: 存在しないユーザーで資産を取得しようとする @@ -41,38 +34,38 @@ ]) ) - # And: 新規拠出を 100,000 円で注文する - oc.new_contribution_order( - CodingInterview::Presentation::NewContributionOrderRequest.new(user_id, "100000") + # And: 新規注文を 100,000 円で注文する + oc.new_order( + CodingInterview::Presentation::NewOrderRequest.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) } + total1 = BigDecimal(asset1.cash_amount) + asset1.stocks.inject(BigDecimal("0")) { |a, e| a + BigDecimal(e.amount_jpy) } expect((total1 - BigDecimal("100000")).abs).to be <= BigDecimal("2") - # Then: 現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の評価額が 38,000 円(40%)、Somy の評価額が 57,000 円(60%) となる + # 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_toyopa.amount_jpy)).to eq(BigDecimal("38000")) + expect(BigDecimal(asset1_somy.amount_jpy)).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") + # When: 追加注文を 100,000 円で注文する + oc.additional_order( + CodingInterview::Presentation::AdditionalOrderRequest.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) } + total2 = BigDecimal(asset2.cash_amount) + asset2.stocks.inject(BigDecimal("0")) { |a, e| a + BigDecimal(e.amount_jpy) } expect((total2 - BigDecimal("200000")).abs).to be <= BigDecimal("4") - # And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる + # 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_toyopa.amount_jpy)).to eq(BigDecimal("76000")) + expect(BigDecimal(asset2_somy.amount_jpy)).to eq(BigDecimal("114000")) expect(BigDecimal(asset2.cash_amount)).to eq(BigDecimal("10000")) # When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする @@ -86,14 +79,14 @@ # 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) } + total3 = BigDecimal(asset3.cash_amount) + asset3.stocks.inject(BigDecimal("0")) { |a, e| a + BigDecimal(e.amount_jpy) } expect((total3 - total2).abs).to be <= BigDecimal("4") - # And: 現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる + # 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_toyopa.amount_jpy)).to eq(BigDecimal("19000")) + expect(BigDecimal(asset3_somy.amount_jpy)).to eq(BigDecimal("171000")) expect(BigDecimal(asset3.cash_amount)).to eq(BigDecimal("10000")) end end From f75d6c20ffb07447fa5157ec86962d115f7fe172 Mon Sep 17 00:00:00 2001 From: krrrr38 Date: Sat, 20 Jun 2026 05:31:58 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor(scala):=20=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=81=AE=E5=89=8A=E9=99=A4=E3=81=A8=E3=83=89?= =?UTF-8?q?=E3=83=A1=E3=82=A4=E3=83=B3=E3=83=A2=E3=83=87=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=81=AB=E3=82=88=E3=82=8B=E7=B0=A1=E7=95=A5?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: krrrr38 --- scala/README.md | 35 ++++---- .../repository/MarketPriceRepository.scala | 10 --- .../application/service/AssetService.scala | 19 ----- .../service/PortfolioService.scala | 80 ------------------- .../usecase/asset/GetAssetUsecase.scala | 10 +-- .../UpdateMarketPriceUsecase.scala | 17 ---- .../order/AdditionalBuyOrderUsecase.scala | 8 +- .../order/NewContributionOrderUsecase.scala | 41 ---------- .../usecase/order/NewOrderUsecase.scala | 38 +++++++++ .../usecase/order/RebalanceOrderUsecase.scala | 8 +- .../codinginterview/domain/AppConstants.scala | 6 +- .../folio/codinginterview/domain/Stock.scala | 72 ++++++++++++++++- .../codinginterview/domain/StockSymbol.scala | 1 + .../folio/codinginterview/domain/UserId.scala | 1 + .../MarketPriceRepositoryImpl.scala | 19 ----- .../infrastructure/server/DummyServer.scala | 32 ++------ .../presentation/AssetController.scala | 4 +- .../presentation/MarketPriceController.scala | 38 --------- .../presentation/OrderController.scala | 22 ++--- .../folio/codinginterview/OrderScenario.scala | 68 ++++++---------- 20 files changed, 180 insertions(+), 349 deletions(-) delete mode 100644 scala/src/main/scala/folio/codinginterview/application/repository/MarketPriceRepository.scala delete mode 100644 scala/src/main/scala/folio/codinginterview/application/service/AssetService.scala delete mode 100644 scala/src/main/scala/folio/codinginterview/application/service/PortfolioService.scala delete mode 100644 scala/src/main/scala/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.scala delete mode 100644 scala/src/main/scala/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.scala create mode 100644 scala/src/main/scala/folio/codinginterview/application/usecase/order/NewOrderUsecase.scala delete mode 100644 scala/src/main/scala/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.scala delete mode 100644 scala/src/main/scala/folio/codinginterview/presentation/MarketPriceController.scala diff --git a/scala/README.md b/scala/README.md index 6c05099..d6e7c7f 100644 --- a/scala/README.md +++ b/scala/README.md @@ -17,33 +17,32 @@ sbt test Repository はモック実装としてin-memoryにデータを保持していますが、RDBを使う想定で回答してください。 -### 株と評価額 +### 銘柄と保有額 -- 株には株数(qty)があります(例: 1株、2株) -- 株には1株あたりの市場価格があります(例: 1株あたり100円) - - 例: 顧客が5株保有している場合、評価額はこの時点では `5株 × 100円 = 500円` となります +- 顧客は銘柄ごとに保有額(円)を保持します(例: A銘柄を 500 円分保有する) + - 簡略化のため、株数や市場価格は扱わず、各銘柄を金額(円)で直接保有するものとします ### ロボアドバイザーサービス - **顧客の口座** - - 新規拠出を行うと、口座がすぐに開きます + - 新規注文を行うと、口座がすぐに開きます - 口座の中で資産を管理することになります - **顧客の資産** - - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します - - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円分をいくつかの株で保有する - - 株は価格で保持するのではなく、株数で保持します - - そのため、市場価格に応じて評価額は変わることになります + - 顧客は現金と銘柄を保有し、総資産の5%は常に現金で保持します + - 例: 総資産105万円のうち5万円を現金として保持し、残り100万円分をいくつかの銘柄で保有する + - 各銘柄毎の資産は金額(円)で保持します - **最適ポートフォリオ** - - サービスが管理する、株の評価額ベースの構成比率 - - 例: A株を30%・B株を70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30%分の株数 + B株95万円*70%分の株数 になるように努める - - 購入時・売却時・リバランス時には、売買後の資産比率が __現在の最適ポートフォリオ__ に近づける形での売買を実施します -- **株の売買** - - 本アプリケーションでは、注文APIを叩くと __即時__ 株の売買が成立し資産に反映出来るものとします + - サービスが管理する、銘柄の保有額ベースの構成比率(現金は含めない) + - 例: A銘柄を30%・B銘柄を70%で保有する場合、総資産105万円のうち 5万円の現金 + A銘柄30万円分 + B銘柄70万円分 になるように努める + - 購入時・売却時・リバランス時には、注文後の資産比率が __現在の最適ポートフォリオ__ に近づける形での調整を実施します +- **資産の調整** + - 本アプリケーションでは、注文APIを叩くと __即時__ 売買が成立し資産に反映出来るものとします - 用語 - - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 - - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 - - 全売却注文: 運用中の株を全て売却すること。 - - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + - 新規注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加注文: 追加で資金を投入すること。この注文を入れると、運用する金額が増える。 + - 全売却注文: 運用中の銘柄を全て売却すること。 + - リバランス注文: 運用されている資産を、サービスで保有する最適ポートフォリオに近づけるよう調整すること。最適ポートフォリオの比率が変更された場合に、その比率へ寄せる。 + - 例: 最適ポートフォリオを `A銘柄30%+B銘柄70%` から `A銘柄50%+B銘柄50%` に変えてからリバランス注文をすると、顧客の口座も最新の最適ポートフォリオ通りの内容になる ## 確認観点 diff --git a/scala/src/main/scala/folio/codinginterview/application/repository/MarketPriceRepository.scala b/scala/src/main/scala/folio/codinginterview/application/repository/MarketPriceRepository.scala deleted file mode 100644 index e048fad..0000000 --- a/scala/src/main/scala/folio/codinginterview/application/repository/MarketPriceRepository.scala +++ /dev/null @@ -1,10 +0,0 @@ -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/service/AssetService.scala b/scala/src/main/scala/folio/codinginterview/application/service/AssetService.scala deleted file mode 100644 index 2d5eba1..0000000 --- a/scala/src/main/scala/folio/codinginterview/application/service/AssetService.scala +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index f4e2cbc..0000000 --- a/scala/src/main/scala/folio/codinginterview/application/service/PortfolioService.scala +++ /dev/null @@ -1,80 +0,0 @@ -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 index 28a92e4..9e047f0 100644 --- a/scala/src/main/scala/folio/codinginterview/application/usecase/asset/GetAssetUsecase.scala +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/asset/GetAssetUsecase.scala @@ -1,8 +1,6 @@ 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 @@ -10,7 +8,7 @@ import scala.concurrent.Future final case class GetAssetUsecaseInput(userId: UserId) -final case class GetAssetStockOutput(symbol: StockSymbol, evaluationAmount: BigDecimal) +final case class GetAssetStockOutput(symbol: StockSymbol, amountJpy: BigDecimal) final case class GetAssetUsecaseOutput( cashAmount: BigDecimal, @@ -23,8 +21,7 @@ object GetAssetUsecaseException { } final class GetAssetUsecase( - accountRepository: AccountRepository, - marketPriceRepository: MarketPriceRepository + accountRepository: AccountRepository )(using ec: ExecutionContext) { def run(input: GetAssetUsecaseInput): Future[GetAssetUsecaseOutput] = { for { @@ -33,10 +30,9 @@ final class GetAssetUsecase( 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)) + GetAssetStockOutput(e.symbol, e.amountJpy) } 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 deleted file mode 100644 index e2da728..0000000 --- a/scala/src/main/scala/folio/codinginterview/application/usecase/market_price/UpdateMarketPriceUsecase.scala +++ /dev/null @@ -1,17 +0,0 @@ -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 index 1fdf150..af3c5c6 100644 --- a/scala/src/main/scala/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.scala +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/order/AdditionalBuyOrderUsecase.scala @@ -1,9 +1,7 @@ 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 @@ -19,8 +17,7 @@ object AdditionalBuyOrderUsecaseException { final class AdditionalBuyOrderUsecase( accountRepository: AccountRepository, - portfolioRepository: PortfolioRepository, - marketPriceRepository: MarketPriceRepository + portfolioRepository: PortfolioRepository )(using ec: ExecutionContext) { def run(input: AdditionalBuyOrderUsecaseInput): Future[Unit] = { if (input.amount < AppConstants.minOperationAmount) { @@ -33,8 +30,7 @@ final class AdditionalBuyOrderUsecase( case None => Future.failed(AdditionalBuyOrderUsecaseException.UserNotFound) } portfolio <- portfolioRepository.get() - prices <- marketPriceRepository.all() - updated = PortfolioService.allocateAdditional(account, input.amount, portfolio, prices) + updated = account.addFunds(input.amount, portfolio) _ <- 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 deleted file mode 100644 index 0cc94be..0000000 --- a/scala/src/main/scala/folio/codinginterview/application/usecase/order/NewContributionOrderUsecase.scala +++ /dev/null @@ -1,41 +0,0 @@ -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/NewOrderUsecase.scala b/scala/src/main/scala/folio/codinginterview/application/usecase/order/NewOrderUsecase.scala new file mode 100644 index 0000000..a7dcdb0 --- /dev/null +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/order/NewOrderUsecase.scala @@ -0,0 +1,38 @@ +package folio.codinginterview.application.usecase.order + +import folio.codinginterview.application.repository.AccountRepository +import folio.codinginterview.application.repository.PortfolioRepository +import folio.codinginterview.domain.Account +import folio.codinginterview.domain.AppConstants +import folio.codinginterview.domain.UserId +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +final case class NewOrderUsecaseInput(userId: UserId, amount: BigDecimal) + +sealed trait NewOrderUsecaseException extends RuntimeException +object NewOrderUsecaseException { + case object UserAlreadyExists extends NewOrderUsecaseException + case object AmountTooSmall extends NewOrderUsecaseException +} + +final class NewOrderUsecase( + accountRepository: AccountRepository, + portfolioRepository: PortfolioRepository +)(using ec: ExecutionContext) { + def run(input: NewOrderUsecaseInput): Future[Unit] = { + if (input.amount < AppConstants.minOperationAmount) { + Future.failed(NewOrderUsecaseException.AmountTooSmall) + } else { + for { + exists <- accountRepository.exists(input.userId) + _ <- + if (exists) Future.failed(NewOrderUsecaseException.UserAlreadyExists) + else Future.unit + portfolio <- portfolioRepository.get() + account = Account.openAccount(input.amount, portfolio) + _ <- 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 index 5a30164..a872e32 100644 --- a/scala/src/main/scala/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.scala +++ b/scala/src/main/scala/folio/codinginterview/application/usecase/order/RebalanceOrderUsecase.scala @@ -1,9 +1,7 @@ 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 @@ -17,8 +15,7 @@ object RebalanceOrderUsecaseException { final class RebalanceOrderUsecase( accountRepository: AccountRepository, - portfolioRepository: PortfolioRepository, - marketPriceRepository: MarketPriceRepository + portfolioRepository: PortfolioRepository )(using ec: ExecutionContext) { def run(input: RebalanceOrderUsecaseInput): Future[Unit] = { for { @@ -28,8 +25,7 @@ final class RebalanceOrderUsecase( case None => Future.failed(RebalanceOrderUsecaseException.UserNotFound) } portfolio <- portfolioRepository.get() - prices <- marketPriceRepository.all() - updated = PortfolioService.rebalance(account, portfolio, prices) + updated = account.rebalance(portfolio) _ <- accountRepository.upsert(input.userId, updated) } yield () } diff --git a/scala/src/main/scala/folio/codinginterview/domain/AppConstants.scala b/scala/src/main/scala/folio/codinginterview/domain/AppConstants.scala index 7afe554..23b36a7 100644 --- a/scala/src/main/scala/folio/codinginterview/domain/AppConstants.scala +++ b/scala/src/main/scala/folio/codinginterview/domain/AppConstants.scala @@ -1,5 +1,6 @@ package folio.codinginterview.domain +/** アプリケーション定数。 */ object AppConstants { val cashRate: BigDecimal = BigDecimal("0.05") @@ -7,11 +8,6 @@ object AppConstants { 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")), diff --git a/scala/src/main/scala/folio/codinginterview/domain/Stock.scala b/scala/src/main/scala/folio/codinginterview/domain/Stock.scala index 503f8e6..ed93f7a 100644 --- a/scala/src/main/scala/folio/codinginterview/domain/Stock.scala +++ b/scala/src/main/scala/folio/codinginterview/domain/Stock.scala @@ -1,9 +1,14 @@ package folio.codinginterview.domain -final case class Stock(symbol: StockSymbol, qty: BigDecimal) +import scala.math.BigDecimal.RoundingMode +/** 保有銘柄(銘柄と保有額)を表す。 */ +final case class Stock(symbol: StockSymbol, amountJpy: 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( @@ -16,4 +21,67 @@ final case class Portfolio(items: Seq[PortfolioItem]) { ) } -final case class Account(cash: BigDecimal, stocks: Seq[Stock]) +/** 口座を表す。 */ +final case class Account(cash: BigDecimal, stocks: Seq[Stock]) { + import Account.floor0 + + /** 口座の総資産(現金 + 各銘柄の保有額)を返す。 */ + def total: BigDecimal = cash + stocks.map(_.amountJpy).sum + + /** 追加注文額を口座へ反映する。最適ポートフォリオの目標額を下回らない範囲で + * 既存の保有額を維持し、ポートフォリオ外の銘柄はそのまま保持する。 */ + def addFunds(amount: BigDecimal, portfolio: Portfolio): Account = { + val totalAfter = total + amount + val targetCash = floor0(totalAfter * AppConstants.cashRate) + val investable = totalAfter - targetCash + val currentAmount: Map[StockSymbol, BigDecimal] = + stocks.map(e => e.symbol -> e.amountJpy).toMap + + val portfolioSymbols = portfolio.items.map(_.symbol).toSet + val newPortfolioStocks = portfolio.items.map { item => + val target = floor0(investable * item.rate) + val current = currentAmount.getOrElse(item.symbol, BigDecimal(0)) + val finalAmt = if (current > target) current else target + Stock(item.symbol, finalAmt) + } + val preservedStocks = stocks.filterNot(e => portfolioSymbols.contains(e.symbol)) + val allStocks = newPortfolioStocks ++ preservedStocks + + val finalAmount = allStocks.map(_.amountJpy).sum + val finalCash = totalAfter - finalAmount + Account(cash = finalCash, stocks = allStocks) + } + + /** 保有資産を最適ポートフォリオの比率に近づける。 */ + def rebalance(portfolio: Portfolio): Account = { + // XXX this implementation might not be correct + val investable = total + var usedForStocks = BigDecimal(0) + val newStocks = portfolio.items.map { item => + val amt = floor0(investable * item.rate) + usedForStocks += amt + Stock(item.symbol, amt) + } + val finalCash = investable - usedForStocks + Account(cash = finalCash, stocks = newStocks) + } +} + +object Account { + /** 円未満を切り捨てる(資産配分はすべて円単位で行う)。 */ + private def floor0(x: BigDecimal): BigDecimal = x.setScale(0, RoundingMode.DOWN) + + /** 新規注文額を、最適ポートフォリオに沿って配分した口座を生成する。 */ + def openAccount(amount: BigDecimal, portfolio: Portfolio): Account = { + val cashFromRate = floor0(amount * AppConstants.cashRate) + val investable = amount - cashFromRate + var usedForStocks = BigDecimal(0) + val stocks = portfolio.items.map { item => + val amt = floor0(investable * item.rate) + usedForStocks += amt + Stock(item.symbol, amt) + } + val residual = investable - usedForStocks + Account(cash = cashFromRate + residual, stocks = stocks) + } +} diff --git a/scala/src/main/scala/folio/codinginterview/domain/StockSymbol.scala b/scala/src/main/scala/folio/codinginterview/domain/StockSymbol.scala index da6c340..a2fe314 100644 --- a/scala/src/main/scala/folio/codinginterview/domain/StockSymbol.scala +++ b/scala/src/main/scala/folio/codinginterview/domain/StockSymbol.scala @@ -1,5 +1,6 @@ package folio.codinginterview.domain +/** 銘柄を表す。 */ enum StockSymbol { case Toyopa, Somy } diff --git a/scala/src/main/scala/folio/codinginterview/domain/UserId.scala b/scala/src/main/scala/folio/codinginterview/domain/UserId.scala index 9bd8216..cc02a39 100644 --- a/scala/src/main/scala/folio/codinginterview/domain/UserId.scala +++ b/scala/src/main/scala/folio/codinginterview/domain/UserId.scala @@ -1,5 +1,6 @@ package folio.codinginterview.domain +/** ユーザーIDを表す。 */ 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/MarketPriceRepositoryImpl.scala b/scala/src/main/scala/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.scala deleted file mode 100644 index 328a8f8..0000000 --- a/scala/src/main/scala/folio/codinginterview/infrastructure/repository/MarketPriceRepositoryImpl.scala +++ /dev/null @@ -1,19 +0,0 @@ -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/server/DummyServer.scala b/scala/src/main/scala/folio/codinginterview/infrastructure/server/DummyServer.scala index 29e2d15..5d9a9fc 100644 --- a/scala/src/main/scala/folio/codinginterview/infrastructure/server/DummyServer.scala +++ b/scala/src/main/scala/folio/codinginterview/infrastructure/server/DummyServer.scala @@ -1,17 +1,14 @@ 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.NewOrderUsecase 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 @@ -19,35 +16,23 @@ import scala.concurrent.ExecutionContext final class DummyServer( val assetController: AssetController, val portfolioController: PortfolioController, - val orderController: OrderController, - val marketPriceController: MarketPriceController + val orderController: OrderController ) 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 getAssetUsecase = new GetAssetUsecase(accountRepository) val getLatestPortfolioUsecase = new GetLatestPortfolioUsecase(portfolioRepository) val updatePortfolioUsecase = new UpdatePortfolioUsecase(portfolioRepository) - val updateMarketPriceUsecase = new UpdateMarketPriceUsecase(marketPriceRepository) - val newContributionOrderUsecase = new NewContributionOrderUsecase( - accountRepository, - portfolioRepository, - marketPriceRepository - ) + val newOrderUsecase = new NewOrderUsecase(accountRepository, portfolioRepository) val additionalBuyOrderUsecase = new AdditionalBuyOrderUsecase( accountRepository, - portfolioRepository, - marketPriceRepository - ) - val rebalanceOrderUsecase = new RebalanceOrderUsecase( - accountRepository, - portfolioRepository, - marketPriceRepository + portfolioRepository ) + val rebalanceOrderUsecase = new RebalanceOrderUsecase(accountRepository, portfolioRepository) val assetController = new AssetController(getAssetUsecase) val portfolioController = new PortfolioController( @@ -55,12 +40,11 @@ object DummyServer { updatePortfolioUsecase ) val orderController = new OrderController( - newContributionOrderUsecase, + newOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase ) - val marketPriceController = new MarketPriceController(updateMarketPriceUsecase) - new DummyServer(assetController, portfolioController, orderController, marketPriceController) + new DummyServer(assetController, portfolioController, orderController) } } diff --git a/scala/src/main/scala/folio/codinginterview/presentation/AssetController.scala b/scala/src/main/scala/folio/codinginterview/presentation/AssetController.scala index ec4d20c..c823d26 100644 --- a/scala/src/main/scala/folio/codinginterview/presentation/AssetController.scala +++ b/scala/src/main/scala/folio/codinginterview/presentation/AssetController.scala @@ -8,7 +8,7 @@ import scala.concurrent.ExecutionContext import scala.concurrent.Future object AssetController { - final case class StockDto(symbol: String, evaluationAmount: String) + final case class StockDto(symbol: String, amountJpy: String) final case class GetAssetRequest(userId: String) final case class GetAssetResponse(cashAmount: String, stocks: Seq[StockDto]) } @@ -27,6 +27,6 @@ final class AssetController( } } yield GetAssetResponse( cashAmount = out.cashAmount.toString, - stocks = out.stocks.map(e => StockDto(e.symbol.toString, e.evaluationAmount.toString)) + stocks = out.stocks.map(e => StockDto(e.symbol.toString, e.amountJpy.toString)) ) } diff --git a/scala/src/main/scala/folio/codinginterview/presentation/MarketPriceController.scala b/scala/src/main/scala/folio/codinginterview/presentation/MarketPriceController.scala deleted file mode 100644 index dd537a5..0000000 --- a/scala/src/main/scala/folio/codinginterview/presentation/MarketPriceController.scala +++ /dev/null @@ -1,38 +0,0 @@ -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 index c1ba763..008911d 100644 --- a/scala/src/main/scala/folio/codinginterview/presentation/OrderController.scala +++ b/scala/src/main/scala/folio/codinginterview/presentation/OrderController.scala @@ -3,9 +3,9 @@ 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.NewOrderUsecase +import folio.codinginterview.application.usecase.order.NewOrderUsecaseException +import folio.codinginterview.application.usecase.order.NewOrderUsecaseInput import folio.codinginterview.application.usecase.order.RebalanceOrderUsecase import folio.codinginterview.application.usecase.order.RebalanceOrderUsecaseException import folio.codinginterview.application.usecase.order.RebalanceOrderUsecaseInput @@ -14,32 +14,32 @@ 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 NewOrderRequest(userId: String, amount: String) + final case class AdditionalOrderRequest(userId: String, amount: String) final case class RebalanceOrderRequest(userId: String) } final class OrderController( - newContributionOrderUsecase: NewContributionOrderUsecase, + newOrderUsecase: NewOrderUsecase, additionalBuyOrderUsecase: AdditionalBuyOrderUsecase, rebalanceOrderUsecase: RebalanceOrderUsecase )(using ec: ExecutionContext) extends PresentationPreparation { import OrderController.* - def newContributionOrder(req: NewContributionOrderRequest): Future[Unit] = + def newOrder(req: NewOrderRequest): Future[Unit] = for { uid <- parseUserId(req.userId) amt <- parseAmount(req.amount) - _ <- newContributionOrderUsecase.run(NewContributionOrderUsecaseInput(uid, amt)).recoverWith { - case NewContributionOrderUsecaseException.UserAlreadyExists => + _ <- newOrderUsecase.run(NewOrderUsecaseInput(uid, amt)).recoverWith { + case NewOrderUsecaseException.UserAlreadyExists => Future.failed(BadRequestException("user already has account")) - case NewContributionOrderUsecaseException.AmountTooSmall => + case NewOrderUsecaseException.AmountTooSmall => Future.failed(BadRequestException("amount is too small")) } } yield () - def additionalContributionOrder(req: AdditionalContributionOrderRequest): Future[Unit] = + def additionalOrder(req: AdditionalOrderRequest): Future[Unit] = for { uid <- parseUserId(req.userId) amt <- parseAmount(req.amount) diff --git a/scala/src/test/scala/folio/codinginterview/OrderScenario.scala b/scala/src/test/scala/folio/codinginterview/OrderScenario.scala index 5ce518c..675692a 100644 --- a/scala/src/test/scala/folio/codinginterview/OrderScenario.scala +++ b/scala/src/test/scala/folio/codinginterview/OrderScenario.scala @@ -2,10 +2,8 @@ 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.AdditionalOrderRequest +import folio.codinginterview.presentation.OrderController.NewOrderRequest import folio.codinginterview.presentation.OrderController.RebalanceOrderRequest import folio.codinginterview.presentation.PortfolioController.PortfolioItemDto import folio.codinginterview.presentation.PortfolioController.UpdateOptimalPortfolioRequest @@ -28,26 +26,20 @@ class OrderScenario extends AnyFeatureSpec with GivenWhenThen with BeforeAndAfte 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 + // initialize 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("新規拠出・追加拠出・リバランスの一連の操作が正しく機能する") { + Scenario("新規注文・追加注文・リバランスの一連の操作が正しく機能する") { val userId = UUID.randomUUID().toString Given("存在しないユーザーで資産を取得しようとする") @@ -63,45 +55,37 @@ class OrderScenario extends AnyFeatureSpec with GivenWhenThen with BeforeAndAfte ) ).await - And("新規拠出を 100,000 円で注文する") - oc.newContributionOrder(NewContributionOrderRequest(userId, "100000")).await + And("新規注文を 100,000 円で行う") + oc.newOrder(NewOrderRequest(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 + val total1 = BigDecimal(asset1.cashAmount) + asset1.stocks.map(e => BigDecimal(e.amountJpy)).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 + Then("現金比率5%に対して現金が 5,000円、最適ポートフォリオに基づき Toyopa の保有額が 38,000 円(40%)、Somy の保有額が 57,000 円(60%) となる") + // cash = floor0(100000 * 0.05) = 5000, investable = 100000 - 5000 = 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 + assertResult(BigDecimal("38000"))(BigDecimal(asset1Toyopa.amountJpy)) // floor0(95000 * 0.40) = 38000 + assertResult(BigDecimal("57000"))(BigDecimal(asset1Somy.amountJpy)) // floor0(95000 * 0.60) = 57000 + assertResult(BigDecimal("5000"))(BigDecimal(asset1.cashAmount)) // 100000 - 38000 - 57000 - When("追加拠出を 100,000 円で注文する") - oc.additionalContributionOrder(AdditionalContributionOrderRequest(userId, "100000")).await + When("追加注文を 100,000 円で行う") + oc.additionalOrder(AdditionalOrderRequest(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 + val total2 = BigDecimal(asset2.cashAmount) + asset2.stocks.map(e => BigDecimal(e.amountJpy)).sum assertResult(true)((total2 - BigDecimal(200000)).abs <= BigDecimal(4)) - And("現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 76,000 円(40%)、Somy の評価額が 114,000 円(60%) となる") + 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 + assertResult(BigDecimal("76000"))(BigDecimal(asset2Toyopa.amountJpy)) // floor0(190000 * 0.40) = 76000 + assertResult(BigDecimal("114000"))(BigDecimal(asset2Somy.amountJpy)) // floor0(190000 * 0.60) = 114000 + assertResult(BigDecimal("10000"))(BigDecimal(asset2.cashAmount)) // 200000 - 76000 - 114000 When("最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする") pc.updateOptimalPortfolio( @@ -113,20 +97,16 @@ class OrderScenario extends AnyFeatureSpec with GivenWhenThen with BeforeAndAfte Then("リバランス後も資産合計がほぼ変わらない") val asset3 = ac.getAsset(GetAssetRequest(userId)).await - val total3 = BigDecimal(asset3.cashAmount) + asset3.stocks.map(e => BigDecimal(e.evaluationAmount)).sum + val total3 = BigDecimal(asset3.cashAmount) + asset3.stocks.map(e => BigDecimal(e.amountJpy)).sum assertResult(true)((total3 - total2).abs <= BigDecimal(4)) - And("現金比率5%に対して現金が 10,000円、最適ポートフォリオに基づき Toyopa の評価額が 19,000 円(10%)、Somy の評価額が 171,000 円(90%) となる") + 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 + assertResult(BigDecimal("19000"))(BigDecimal(asset3Toyopa.amountJpy)) // floor0(190000 * 0.10) = 19000 + assertResult(BigDecimal("171000"))(BigDecimal(asset3Somy.amountJpy)) // floor0(190000 * 0.90) = 171000 + assertResult(BigDecimal("10000"))(BigDecimal(asset3.cashAmount)) // 200000 - 19000 - 171000 } } } From a7d03ffdac89e6a9011229e8be20d76f324b8524 Mon Sep 17 00:00:00 2001 From: krrrr38 Date: Sat, 20 Jun 2026 05:32:02 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor(typescript):=20=E4=B8=8D?= =?UTF-8?q?=E8=A6=81=E6=A9=9F=E8=83=BD=E3=81=AE=E5=89=8A=E9=99=A4=E3=81=A8?= =?UTF-8?q?=E3=83=89=E3=83=A1=E3=82=A4=E3=83=B3=E3=83=A2=E3=83=87=E3=83=AB?= =?UTF-8?q?=E3=81=AE=E6=95=B4=E7=90=86=E3=81=AB=E3=82=88=E3=82=8B=E7=B0=A1?= =?UTF-8?q?=E7=95=A5=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: krrrr38 --- typescript/README.md | 35 ++++--- .../repository/accountRepository.ts | 2 +- .../repository/marketPriceRepository.ts | 8 -- .../src/application/service/assetService.ts | 20 ---- .../application/service/portfolioService.ts | 87 ----------------- .../usecase/asset/getAssetUsecase.ts | 12 +-- .../market_price/updateMarketPriceUsecase.ts | 23 ----- .../order/additionalBuyOrderUsecase.ts | 6 +- .../order/newContributionOrderUsecase.ts | 46 --------- .../usecase/order/newOrderUsecase.ts | 43 +++++++++ .../usecase/order/rebalanceOrderUsecase.ts | 6 +- typescript/src/domain/account.ts | 76 +++++++++++++++ typescript/src/domain/appConstants.ts | 5 +- typescript/src/domain/stock.ts | 9 +- typescript/src/domain/stockSymbol.ts | 1 + typescript/src/domain/userId.ts | 1 + .../repository/accountRepositoryImpl.ts | 2 +- .../repository/marketPriceRepositoryImpl.ts | 16 ---- .../src/infrastructure/server/dummyServer.ts | 35 ++----- .../src/presentation/assetController.ts | 4 +- .../src/presentation/marketPriceController.ts | 38 -------- .../src/presentation/orderController.ts | 24 ++--- typescript/tests/orderScenario.test.ts | 96 +++++++------------ 23 files changed, 203 insertions(+), 392 deletions(-) delete mode 100644 typescript/src/application/repository/marketPriceRepository.ts delete mode 100644 typescript/src/application/service/assetService.ts delete mode 100644 typescript/src/application/service/portfolioService.ts delete mode 100644 typescript/src/application/usecase/market_price/updateMarketPriceUsecase.ts delete mode 100644 typescript/src/application/usecase/order/newContributionOrderUsecase.ts create mode 100644 typescript/src/application/usecase/order/newOrderUsecase.ts create mode 100644 typescript/src/domain/account.ts delete mode 100644 typescript/src/infrastructure/repository/marketPriceRepositoryImpl.ts delete mode 100644 typescript/src/presentation/marketPriceController.ts diff --git a/typescript/README.md b/typescript/README.md index 6dd6aec..bd35026 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -21,33 +21,32 @@ npm test Repository はモック実装としてin-memoryにデータを保持していますが、RDBを使う想定で回答してください。 -### 株と評価額 +### 銘柄と保有額 -- 株には株数(qty)があります(例: 1株、2株) -- 株には1株あたりの市場価格があります(例: 1株あたり100円) - - 例: 顧客が5株保有している場合、評価額はこの時点では `5株 × 100円 = 500円` となります +- 顧客は銘柄ごとに保有額(円)を保持します(例: A銘柄を 500 円分保有する) + - 簡略化のため、株数や市場価格は扱わず、各銘柄を金額(円)で直接保有するものとします ### ロボアドバイザーサービス - **顧客の口座** - - 新規拠出を行うと、口座がすぐに開きます + - 新規注文を行うと、口座がすぐに開きます - 口座の中で資産を管理することになります - **顧客の資産** - - 顧客は現金と株を保有し、総資産の5%は常に現金で保持します - - 例: 総資産100万円のうち5万円を現金として保持し、残り95万円分をいくつかの株で保有する - - 株は価格で保持するのではなく、株数で保持します - - そのため、市場価格に応じて評価額は変わることになります + - 顧客は現金と銘柄を保有し、総資産の5%は常に現金で保持します + - 例: 総資産105万円のうち5万円を現金として保持し、残り100万円分をいくつかの銘柄で保有する + - 各銘柄毎の資産は金額(円)で保持します - **最適ポートフォリオ** - - サービスが管理する、株の評価額ベースの構成比率 - - 例: A株を30%・B株を70%で保有する場合、総資産100万円のうち 5万円の現金 + A株95万円*30%分の株数 + B株95万円*70%分の株数 になるように努める - - 購入時・売却時・リバランス時には、売買後の資産比率が __現在の最適ポートフォリオ__ に近づける形での売買を実施します -- **株の売買** - - 本アプリケーションでは、注文APIを叩くと __即時__ 株の売買が成立し資産に反映出来るものとします + - サービスが管理する、銘柄の保有額ベースの構成比率(現金は含めない) + - 例: A銘柄を30%・B銘柄を70%で保有する場合、総資産105万円のうち 5万円の現金 + A銘柄30万円分 + B銘柄70万円分 になるように努める + - 購入時・売却時・リバランス時には、注文後の資産比率が __現在の最適ポートフォリオ__ に近づける形での調整を実施します +- **資産の調整** + - 本アプリケーションでは、注文APIを叩くと __即時__ 売買が成立し資産に反映出来るものとします - 用語 - - 新規拠出注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 - - 追加拠出注文: 追加で資金を投入すること。この注文を入れると、運用する株が増える。 - - 全売却注文: 運用中の株を全て売却すること。 - - リバランス注文: 運用されている株を、サービスで保有する最適ポートフォリオに近づける株の売買をすること。 + - 新規注文: 初めて資金を投入すること。この注文を入れることで、資産運用が始まる。 + - 追加注文: 追加で資金を投入すること。この注文を入れると、運用する金額が増える。 + - 全売却注文: 運用中の銘柄を全て売却すること。 + - リバランス注文: 運用されている資産を、サービスで保有する最適ポートフォリオに近づけるよう調整すること。最適ポートフォリオの比率が変更された場合に、その比率へ寄せる。 + - 例: 最適ポートフォリオを `A銘柄30%+B銘柄70%` から `A銘柄50%+B銘柄50%` に変えてからリバランス注文をすると、顧客の口座も最新の最適ポートフォリオ通りの内容になる ## 確認観点 diff --git a/typescript/src/application/repository/accountRepository.ts b/typescript/src/application/repository/accountRepository.ts index ea17bbb..50e26db 100644 --- a/typescript/src/application/repository/accountRepository.ts +++ b/typescript/src/application/repository/accountRepository.ts @@ -1,4 +1,4 @@ -import { Account } from "../../domain/stock.js"; +import { Account } from "../../domain/account.js"; import { UserId } from "../../domain/userId.js"; /** 口座管理リポジトリ。 */ diff --git a/typescript/src/application/repository/marketPriceRepository.ts b/typescript/src/application/repository/marketPriceRepository.ts deleted file mode 100644 index 6b17325..0000000 --- a/typescript/src/application/repository/marketPriceRepository.ts +++ /dev/null @@ -1,8 +0,0 @@ -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/service/assetService.ts b/typescript/src/application/service/assetService.ts deleted file mode 100644 index dbfee4f..0000000 --- a/typescript/src/application/service/assetService.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 45ff375..0000000 --- a/typescript/src/application/service/portfolioService.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 index e0bcd36..424c278 100644 --- a/typescript/src/application/usecase/asset/getAssetUsecase.ts +++ b/typescript/src/application/usecase/asset/getAssetUsecase.ts @@ -1,7 +1,5 @@ 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"; @@ -11,7 +9,7 @@ export interface GetAssetUsecaseInput { export interface GetAssetStockOutput { symbol: StockSymbol; - evaluationAmount: Decimal; + amountJpy: Decimal; } export interface GetAssetUsecaseOutput { @@ -27,20 +25,16 @@ export class UserNotFoundException extends GetAssetUsecaseException { } export class GetAssetUsecase { - constructor( - private readonly accountRepository: AccountRepository, - private readonly marketPriceRepository: MarketPriceRepository, - ) {} + constructor(private readonly accountRepository: AccountRepository) {} 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), + amountJpy: e.amountJpy, })); 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 deleted file mode 100644 index 19b9ea1..0000000 --- a/typescript/src/application/usecase/market_price/updateMarketPriceUsecase.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 index df1e939..b6ee0ac 100644 --- a/typescript/src/application/usecase/order/additionalBuyOrderUsecase.ts +++ b/typescript/src/application/usecase/order/additionalBuyOrderUsecase.ts @@ -1,8 +1,6 @@ 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"; @@ -27,7 +25,6 @@ export class AdditionalBuyOrderUsecase { constructor( private readonly accountRepository: AccountRepository, private readonly portfolioRepository: PortfolioRepository, - private readonly marketPriceRepository: MarketPriceRepository, ) {} async run(input: AdditionalBuyOrderUsecaseInput): Promise { @@ -39,8 +36,7 @@ export class AdditionalBuyOrderUsecase { throw new AdditionalBuyUserNotFoundException(); } const portfolio = await this.portfolioRepository.get(); - const prices = await this.marketPriceRepository.all(); - const updated = PortfolioService.allocateAdditional(account, input.amount, portfolio, prices); + const updated = account.addFunds(input.amount, portfolio); 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 deleted file mode 100644 index 399952a..0000000 --- a/typescript/src/application/usecase/order/newContributionOrderUsecase.ts +++ /dev/null @@ -1,46 +0,0 @@ -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/newOrderUsecase.ts b/typescript/src/application/usecase/order/newOrderUsecase.ts new file mode 100644 index 0000000..809bac3 --- /dev/null +++ b/typescript/src/application/usecase/order/newOrderUsecase.ts @@ -0,0 +1,43 @@ +import Decimal from "decimal.js"; +import { AccountRepository } from "../../repository/accountRepository.js"; +import { PortfolioRepository } from "../../repository/portfolioRepository.js"; +import { Account } from "../../../domain/account.js"; +import { AppConstants } from "../../../domain/appConstants.js"; +import { UserId } from "../../../domain/userId.js"; + +export interface NewOrderUsecaseInput { + userId: UserId; + amount: Decimal; +} + +export class NewOrderUsecaseException extends Error {} +export class NewOrderUserAlreadyExistsException extends NewOrderUsecaseException { + constructor() { + super("user already exists"); + } +} +export class NewOrderAmountTooSmallException extends NewOrderUsecaseException { + constructor() { + super("amount too small"); + } +} + +export class NewOrderUsecase { + constructor( + private readonly accountRepository: AccountRepository, + private readonly portfolioRepository: PortfolioRepository, + ) {} + + async run(input: NewOrderUsecaseInput): Promise { + if (input.amount.lessThan(AppConstants.minOperationAmount)) { + throw new NewOrderAmountTooSmallException(); + } + const exists = await this.accountRepository.exists(input.userId); + if (exists) { + throw new NewOrderUserAlreadyExistsException(); + } + const portfolio = await this.portfolioRepository.get(); + const account = Account.openAccount(input.amount, portfolio); + 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 index 4686e7c..2b267c0 100644 --- a/typescript/src/application/usecase/order/rebalanceOrderUsecase.ts +++ b/typescript/src/application/usecase/order/rebalanceOrderUsecase.ts @@ -1,7 +1,5 @@ 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 { @@ -19,7 +17,6 @@ export class RebalanceOrderUsecase { constructor( private readonly accountRepository: AccountRepository, private readonly portfolioRepository: PortfolioRepository, - private readonly marketPriceRepository: MarketPriceRepository, ) {} async run(input: RebalanceOrderUsecaseInput): Promise { @@ -28,8 +25,7 @@ export class RebalanceOrderUsecase { throw new RebalanceUserNotFoundException(); } const portfolio = await this.portfolioRepository.get(); - const prices = await this.marketPriceRepository.all(); - const updated = PortfolioService.rebalance(account, portfolio, prices); + const updated = account.rebalance(portfolio); await this.accountRepository.upsert(input.userId, updated); } } diff --git a/typescript/src/domain/account.ts b/typescript/src/domain/account.ts new file mode 100644 index 0000000..2a317d2 --- /dev/null +++ b/typescript/src/domain/account.ts @@ -0,0 +1,76 @@ +import Decimal from "decimal.js"; +import { Portfolio, Stock } from "./stock.js"; +import { StockSymbol } from "./stockSymbol.js"; +import { AppConstants } from "./appConstants.js"; + +// floor0 は円未満を切り捨てる(資産配分はすべて円単位で行う)。 +const floor0 = (x: Decimal): Decimal => x.toDecimalPlaces(0, Decimal.ROUND_DOWN); + +// Account は口座を表す。 +export class Account { + constructor( + readonly cash: Decimal, + readonly stocks: ReadonlyArray, + ) {} + + // total は口座の総資産(現金 + 各銘柄の保有額)を返す。 + total(): Decimal { + return this.stocks.reduce((acc, s) => acc.plus(s.amountJpy), this.cash); + } + + // openAccount は新規注文額を、最適ポートフォリオに沿って配分した口座を生成する。 + static openAccount(amount: Decimal, portfolio: Portfolio): Account { + const cashFromRate = floor0(amount.times(AppConstants.cashRate)); + const investable = amount.minus(cashFromRate); + const stocks: Stock[] = []; + let usedForStocks = new Decimal(0); + for (const item of portfolio.items) { + const amt = floor0(investable.times(item.rate)); + stocks.push({ symbol: item.symbol, amountJpy: amt }); + usedForStocks = usedForStocks.plus(amt); + } + const residual = investable.minus(usedForStocks); + return new Account(cashFromRate.plus(residual), stocks); + } + + // addFunds は追加注文額を口座へ反映する。最適ポートフォリオの目標額を下回らない範囲で + // 既存の保有額を維持し、ポートフォリオ外の銘柄はそのまま保持する。 + addFunds(amount: Decimal, portfolio: Portfolio): Account { + const totalAfter = this.total().plus(amount); + const targetCash = floor0(totalAfter.times(AppConstants.cashRate)); + const investable = totalAfter.minus(targetCash); + + const currentAmount = new Map( + this.stocks.map((s) => [s.symbol, s.amountJpy]), + ); + const portfolioSymbols = new Set(portfolio.items.map((i) => i.symbol)); + + const newPortfolioStocks: Stock[] = portfolio.items.map((item) => { + const target = floor0(investable.times(item.rate)); + const current = currentAmount.get(item.symbol) ?? new Decimal(0); + const final = current.greaterThan(target) ? current : target; + return { symbol: item.symbol, amountJpy: final }; + }); + + const preservedStocks = this.stocks.filter((s) => !portfolioSymbols.has(s.symbol)); + const allStocks = [...newPortfolioStocks, ...preservedStocks]; + const finalAmount = allStocks.reduce((acc, s) => acc.plus(s.amountJpy), new Decimal(0)); + const finalCash = totalAfter.minus(finalAmount); + return new Account(finalCash, allStocks); + } + + // rebalance は保有資産を最適ポートフォリオの比率に近づける。 + rebalance(portfolio: Portfolio): Account { + // XXX this implementation might not be correct + const investable = this.total(); + const newStocks: Stock[] = []; + let usedForStocks = new Decimal(0); + for (const item of portfolio.items) { + const amt = floor0(investable.times(item.rate)); + newStocks.push({ symbol: item.symbol, amountJpy: amt }); + usedForStocks = usedForStocks.plus(amt); + } + const finalCash = investable.minus(usedForStocks); + return new Account(finalCash, newStocks); + } +} diff --git a/typescript/src/domain/appConstants.ts b/typescript/src/domain/appConstants.ts index 8a41d9d..abe1a63 100644 --- a/typescript/src/domain/appConstants.ts +++ b/typescript/src/domain/appConstants.ts @@ -2,14 +2,11 @@ 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 index 5350ce0..d9f50b8 100644 --- a/typescript/src/domain/stock.ts +++ b/typescript/src/domain/stock.ts @@ -1,9 +1,10 @@ import Decimal from "decimal.js"; import { StockSymbol } from "./stockSymbol.js"; +// Stock は保有銘柄(銘柄と保有額)を表す。 export interface Stock { symbol: StockSymbol; - qty: Decimal; + amountJpy: Decimal; } export interface PortfolioItem { @@ -11,6 +12,7 @@ export interface PortfolioItem { rate: Decimal; } +// Portfolio は最適ポートフォリオ(銘柄ごとの構成比率)を表す。 export class Portfolio { readonly items: ReadonlyArray; @@ -29,8 +31,3 @@ export class Portfolio { this.items = items; } } - -export interface Account { - cash: Decimal; - stocks: ReadonlyArray; -} diff --git a/typescript/src/domain/stockSymbol.ts b/typescript/src/domain/stockSymbol.ts index 72af8c8..6b1d07d 100644 --- a/typescript/src/domain/stockSymbol.ts +++ b/typescript/src/domain/stockSymbol.ts @@ -1,3 +1,4 @@ +/** 銘柄を表す。 */ export type StockSymbol = "Toyopa" | "Somy"; export const StockSymbol = { diff --git a/typescript/src/domain/userId.ts b/typescript/src/domain/userId.ts index a76169d..4c5b7b8 100644 --- a/typescript/src/domain/userId.ts +++ b/typescript/src/domain/userId.ts @@ -1,3 +1,4 @@ +/** ユーザーIDを表す。 */ export class UserId { readonly value: string; diff --git a/typescript/src/infrastructure/repository/accountRepositoryImpl.ts b/typescript/src/infrastructure/repository/accountRepositoryImpl.ts index e40540b..b0ca86c 100644 --- a/typescript/src/infrastructure/repository/accountRepositoryImpl.ts +++ b/typescript/src/infrastructure/repository/accountRepositoryImpl.ts @@ -1,5 +1,5 @@ import { AccountRepository } from "../../application/repository/accountRepository.js"; -import { Account } from "../../domain/stock.js"; +import { Account } from "../../domain/account.js"; import { UserId } from "../../domain/userId.js"; export class AccountRepositoryImpl implements AccountRepository { diff --git a/typescript/src/infrastructure/repository/marketPriceRepositoryImpl.ts b/typescript/src/infrastructure/repository/marketPriceRepositoryImpl.ts deleted file mode 100644 index d06a47c..0000000 --- a/typescript/src/infrastructure/repository/marketPriceRepositoryImpl.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/server/dummyServer.ts b/typescript/src/infrastructure/server/dummyServer.ts index 6c531fc..03c619c 100644 --- a/typescript/src/infrastructure/server/dummyServer.ts +++ b/typescript/src/infrastructure/server/dummyServer.ts @@ -1,60 +1,43 @@ 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 { NewOrderUsecase } from "../../application/usecase/order/newOrderUsecase.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 getAssetUsecase = new GetAssetUsecase(accountRepository); const getLatestPortfolioUsecase = new GetLatestPortfolioUsecase(portfolioRepository); const updatePortfolioUsecase = new UpdatePortfolioUsecase(portfolioRepository); - const updateMarketPriceUsecase = new UpdateMarketPriceUsecase(marketPriceRepository); - const newContributionOrderUsecase = new NewContributionOrderUsecase( - accountRepository, - portfolioRepository, - marketPriceRepository, - ); + const newOrderUsecase = new NewOrderUsecase(accountRepository, portfolioRepository); const additionalBuyOrderUsecase = new AdditionalBuyOrderUsecase( accountRepository, portfolioRepository, - marketPriceRepository, - ); - const rebalanceOrderUsecase = new RebalanceOrderUsecase( - accountRepository, - portfolioRepository, - marketPriceRepository, ); + const rebalanceOrderUsecase = new RebalanceOrderUsecase(accountRepository, portfolioRepository); const assetController = new AssetController(getAssetUsecase); const portfolioController = new PortfolioController( @@ -62,17 +45,11 @@ export class DummyServer { updatePortfolioUsecase, ); const orderController = new OrderController( - newContributionOrderUsecase, + newOrderUsecase, additionalBuyOrderUsecase, rebalanceOrderUsecase, ); - const marketPriceController = new MarketPriceController(updateMarketPriceUsecase); - return new DummyServer( - assetController, - portfolioController, - orderController, - marketPriceController, - ); + return new DummyServer(assetController, portfolioController, orderController); } } diff --git a/typescript/src/presentation/assetController.ts b/typescript/src/presentation/assetController.ts index 8852687..ff9ad9d 100644 --- a/typescript/src/presentation/assetController.ts +++ b/typescript/src/presentation/assetController.ts @@ -7,7 +7,7 @@ import { parseUserId } from "./presentationPreparation.js"; export interface StockDto { symbol: string; - evaluationAmount: string; + amountJpy: string; } export interface GetAssetRequest { @@ -30,7 +30,7 @@ export class AssetController { cashAmount: out.cashAmount.toString(), stocks: out.stocks.map((e) => ({ symbol: e.symbol, - evaluationAmount: e.evaluationAmount.toString(), + amountJpy: e.amountJpy.toString(), })), }; } catch (e) { diff --git a/typescript/src/presentation/marketPriceController.ts b/typescript/src/presentation/marketPriceController.ts deleted file mode 100644 index 088875d..0000000 --- a/typescript/src/presentation/marketPriceController.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 index 8e322d1..f0b7ddf 100644 --- a/typescript/src/presentation/orderController.ts +++ b/typescript/src/presentation/orderController.ts @@ -4,10 +4,10 @@ import { AdditionalBuyUserNotFoundException, } from "../application/usecase/order/additionalBuyOrderUsecase.js"; import { - NewContributionAmountTooSmallException, - NewContributionOrderUsecase, - NewContributionUserAlreadyExistsException, -} from "../application/usecase/order/newContributionOrderUsecase.js"; + NewOrderAmountTooSmallException, + NewOrderUsecase, + NewOrderUserAlreadyExistsException, +} from "../application/usecase/order/newOrderUsecase.js"; import { RebalanceOrderUsecase, RebalanceUserNotFoundException, @@ -15,12 +15,12 @@ import { import { BadRequestException } from "./presentationException.js"; import { parseAmount, parseUserId } from "./presentationPreparation.js"; -export interface NewContributionOrderRequest { +export interface NewOrderRequest { userId: string; amount: string; } -export interface AdditionalContributionOrderRequest { +export interface AdditionalOrderRequest { userId: string; amount: string; } @@ -31,28 +31,28 @@ export interface RebalanceOrderRequest { export class OrderController { constructor( - private readonly newContributionOrderUsecase: NewContributionOrderUsecase, + private readonly newOrderUsecase: NewOrderUsecase, private readonly additionalBuyOrderUsecase: AdditionalBuyOrderUsecase, private readonly rebalanceOrderUsecase: RebalanceOrderUsecase, ) {} - async newContributionOrder(req: NewContributionOrderRequest): Promise { + async newOrder(req: NewOrderRequest): Promise { const uid = parseUserId(req.userId); const amt = parseAmount(req.amount); try { - await this.newContributionOrderUsecase.run({ userId: uid, amount: amt }); + await this.newOrderUsecase.run({ userId: uid, amount: amt }); } catch (e) { - if (e instanceof NewContributionUserAlreadyExistsException) { + if (e instanceof NewOrderUserAlreadyExistsException) { throw new BadRequestException("user already has account"); } - if (e instanceof NewContributionAmountTooSmallException) { + if (e instanceof NewOrderAmountTooSmallException) { throw new BadRequestException("amount is too small"); } throw e; } } - async additionalContributionOrder(req: AdditionalContributionOrderRequest): Promise { + async additionalOrder(req: AdditionalOrderRequest): Promise { const uid = parseUserId(req.userId); const amt = parseAmount(req.amount); try { diff --git a/typescript/tests/orderScenario.test.ts b/typescript/tests/orderScenario.test.ts index c78c11b..77af436 100644 --- a/typescript/tests/orderScenario.test.ts +++ b/typescript/tests/orderScenario.test.ts @@ -15,100 +15,72 @@ describe("Investment Operation", () => { { 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; - + it("存在しないユーザーへのリクエストは BadRequestException を返す", async () => { const userId = randomUUID(); - - // Given: 存在しないユーザーで資産を取得しようとする let notFound: unknown; try { - await ac.getAsset({ userId }); + await server.assetController.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 }); + it("asset1: 新規注文 100,000円 が正しく機能する", async () => { + const userId = randomUUID(); + // cash = floor0(100000 * 0.05) = 5000, investable = 100000 - 5000 = 95000 + 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.evaluationAmount)) + .map((e) => new Decimal(e.amountJpy)) .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" }); + 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 + }); - // Then: 資産合計が約 200000 円になる - const asset2 = await ac.getAsset({ userId }); + it("asset2: 追加注文 100,000円 が正しく機能する", async () => { + const userId = randomUUID(); + // totalAfter = 200000; investable = 200000 - floor0(200000 * 0.05) = 190000 + await server.orderController.newOrder({ userId, amount: "100000" }); + await server.orderController.additionalOrder({ userId, amount: "100000" }); + const asset2 = await server.assetController.getAsset({ userId }); const total2 = asset2.stocks - .map((e) => new Decimal(e.evaluationAmount)) + .map((e) => new Decimal(e.amountJpy)) .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 + 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 + }); - // When: 最適ポートフォリオを Toyopa=10%, Somy=90% に変更して、リバランス注文をする - await pc.updateOptimalPortfolio({ + it("asset3: リバランス注文(課題2: 現金比率を確保しないバグがある)", async () => { + const userId = randomUUID(); + await server.orderController.newOrder({ userId, amount: "100000" }); + await server.orderController.additionalOrder({ userId, amount: "100000" }); + await server.portfolioController.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%) となる + await server.orderController.rebalanceOrder({ userId }); // total = 200000; investable = 200000 - floor0(200000 * 0.05) = 190000 + const asset3 = await server.assetController.getAsset({ userId }); 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 + 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 }); }); From 6511cfeb07b8fb8aba4ecfcc97a7181289853e16 Mon Sep 17 00:00:00 2001 From: krrrr38 Date: Sat, 20 Jun 2026 16:05:03 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor(typescript):=20=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=88=E3=81=8B=E3=82=89=E4=B8=8D?= =?UTF-8?q?=E8=A6=81=E3=81=AA=20.js=20=E6=8B=A1=E5=BC=B5=E5=AD=90=E3=82=92?= =?UTF-8?q?=E9=99=A4=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsx を直接使う構成では .js 拡張子は不要なため、 moduleResolution を Bundler から node に変更し、 全相対インポートの .js を削除した。 Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: krrrr38 --- .../repository/accountRepository.ts | 4 ++-- .../repository/portfolioRepository.ts | 2 +- .../usecase/asset/getAssetUsecase.ts | 6 ++--- .../order/additionalBuyOrderUsecase.ts | 8 +++---- .../usecase/order/newOrderUsecase.ts | 10 ++++----- .../usecase/order/rebalanceOrderUsecase.ts | 6 ++--- .../portfolio/getLatestPortfolioUsecase.ts | 4 ++-- .../portfolio/updatePortfolioUsecase.ts | 6 ++--- typescript/src/domain/account.ts | 6 ++--- typescript/src/domain/appConstants.ts | 4 ++-- typescript/src/domain/stock.ts | 2 +- .../repository/accountRepositoryImpl.ts | 6 ++--- .../repository/portfolioRepositoryImpl.ts | 6 ++--- .../src/infrastructure/server/dummyServer.ts | 22 +++++++++---------- typescript/src/infrastructure/server/main.ts | 2 +- .../src/presentation/assetController.ts | 6 ++--- .../src/presentation/orderController.ts | 10 ++++----- .../src/presentation/portfolioController.ts | 8 +++---- .../presentation/presentationPreparation.ts | 4 ++-- typescript/tsconfig.json | 2 +- 20 files changed, 62 insertions(+), 62 deletions(-) diff --git a/typescript/src/application/repository/accountRepository.ts b/typescript/src/application/repository/accountRepository.ts index 50e26db..84eb8f2 100644 --- a/typescript/src/application/repository/accountRepository.ts +++ b/typescript/src/application/repository/accountRepository.ts @@ -1,5 +1,5 @@ -import { Account } from "../../domain/account.js"; -import { UserId } from "../../domain/userId.js"; +import { Account } from "../../domain/account"; +import { UserId } from "../../domain/userId"; /** 口座管理リポジトリ。 */ export interface AccountRepository { diff --git a/typescript/src/application/repository/portfolioRepository.ts b/typescript/src/application/repository/portfolioRepository.ts index 0db9565..89d2839 100644 --- a/typescript/src/application/repository/portfolioRepository.ts +++ b/typescript/src/application/repository/portfolioRepository.ts @@ -1,4 +1,4 @@ -import { Portfolio } from "../../domain/stock.js"; +import { Portfolio } from "../../domain/stock"; /** 最適ポートフォリオリポジトリ。 */ export interface PortfolioRepository { diff --git a/typescript/src/application/usecase/asset/getAssetUsecase.ts b/typescript/src/application/usecase/asset/getAssetUsecase.ts index 424c278..d951da8 100644 --- a/typescript/src/application/usecase/asset/getAssetUsecase.ts +++ b/typescript/src/application/usecase/asset/getAssetUsecase.ts @@ -1,7 +1,7 @@ import Decimal from "decimal.js"; -import { AccountRepository } from "../../repository/accountRepository.js"; -import { StockSymbol } from "../../../domain/stockSymbol.js"; -import { UserId } from "../../../domain/userId.js"; +import { AccountRepository } from "../../repository/accountRepository"; +import { StockSymbol } from "../../../domain/stockSymbol"; +import { UserId } from "../../../domain/userId"; export interface GetAssetUsecaseInput { userId: UserId; diff --git a/typescript/src/application/usecase/order/additionalBuyOrderUsecase.ts b/typescript/src/application/usecase/order/additionalBuyOrderUsecase.ts index b6ee0ac..9885229 100644 --- a/typescript/src/application/usecase/order/additionalBuyOrderUsecase.ts +++ b/typescript/src/application/usecase/order/additionalBuyOrderUsecase.ts @@ -1,8 +1,8 @@ import Decimal from "decimal.js"; -import { AccountRepository } from "../../repository/accountRepository.js"; -import { PortfolioRepository } from "../../repository/portfolioRepository.js"; -import { AppConstants } from "../../../domain/appConstants.js"; -import { UserId } from "../../../domain/userId.js"; +import { AccountRepository } from "../../repository/accountRepository"; +import { PortfolioRepository } from "../../repository/portfolioRepository"; +import { AppConstants } from "../../../domain/appConstants"; +import { UserId } from "../../../domain/userId"; export interface AdditionalBuyOrderUsecaseInput { userId: UserId; diff --git a/typescript/src/application/usecase/order/newOrderUsecase.ts b/typescript/src/application/usecase/order/newOrderUsecase.ts index 809bac3..7646b74 100644 --- a/typescript/src/application/usecase/order/newOrderUsecase.ts +++ b/typescript/src/application/usecase/order/newOrderUsecase.ts @@ -1,9 +1,9 @@ import Decimal from "decimal.js"; -import { AccountRepository } from "../../repository/accountRepository.js"; -import { PortfolioRepository } from "../../repository/portfolioRepository.js"; -import { Account } from "../../../domain/account.js"; -import { AppConstants } from "../../../domain/appConstants.js"; -import { UserId } from "../../../domain/userId.js"; +import { AccountRepository } from "../../repository/accountRepository"; +import { PortfolioRepository } from "../../repository/portfolioRepository"; +import { Account } from "../../../domain/account"; +import { AppConstants } from "../../../domain/appConstants"; +import { UserId } from "../../../domain/userId"; export interface NewOrderUsecaseInput { userId: UserId; diff --git a/typescript/src/application/usecase/order/rebalanceOrderUsecase.ts b/typescript/src/application/usecase/order/rebalanceOrderUsecase.ts index 2b267c0..7d5c78c 100644 --- a/typescript/src/application/usecase/order/rebalanceOrderUsecase.ts +++ b/typescript/src/application/usecase/order/rebalanceOrderUsecase.ts @@ -1,6 +1,6 @@ -import { AccountRepository } from "../../repository/accountRepository.js"; -import { PortfolioRepository } from "../../repository/portfolioRepository.js"; -import { UserId } from "../../../domain/userId.js"; +import { AccountRepository } from "../../repository/accountRepository"; +import { PortfolioRepository } from "../../repository/portfolioRepository"; +import { UserId } from "../../../domain/userId"; export interface RebalanceOrderUsecaseInput { userId: UserId; diff --git a/typescript/src/application/usecase/portfolio/getLatestPortfolioUsecase.ts b/typescript/src/application/usecase/portfolio/getLatestPortfolioUsecase.ts index acb4540..87c4ce6 100644 --- a/typescript/src/application/usecase/portfolio/getLatestPortfolioUsecase.ts +++ b/typescript/src/application/usecase/portfolio/getLatestPortfolioUsecase.ts @@ -1,6 +1,6 @@ import Decimal from "decimal.js"; -import { PortfolioRepository } from "../../repository/portfolioRepository.js"; -import { StockSymbol } from "../../../domain/stockSymbol.js"; +import { PortfolioRepository } from "../../repository/portfolioRepository"; +import { StockSymbol } from "../../../domain/stockSymbol"; export interface GetLatestPortfolioItemOutput { symbol: StockSymbol; diff --git a/typescript/src/application/usecase/portfolio/updatePortfolioUsecase.ts b/typescript/src/application/usecase/portfolio/updatePortfolioUsecase.ts index 1bae31a..4af9596 100644 --- a/typescript/src/application/usecase/portfolio/updatePortfolioUsecase.ts +++ b/typescript/src/application/usecase/portfolio/updatePortfolioUsecase.ts @@ -1,7 +1,7 @@ import Decimal from "decimal.js"; -import { PortfolioRepository } from "../../repository/portfolioRepository.js"; -import { Portfolio } from "../../../domain/stock.js"; -import { StockSymbol } from "../../../domain/stockSymbol.js"; +import { PortfolioRepository } from "../../repository/portfolioRepository"; +import { Portfolio } from "../../../domain/stock"; +import { StockSymbol } from "../../../domain/stockSymbol"; export interface UpdatePortfolioItemInput { symbol: StockSymbol; diff --git a/typescript/src/domain/account.ts b/typescript/src/domain/account.ts index 2a317d2..925acbd 100644 --- a/typescript/src/domain/account.ts +++ b/typescript/src/domain/account.ts @@ -1,7 +1,7 @@ import Decimal from "decimal.js"; -import { Portfolio, Stock } from "./stock.js"; -import { StockSymbol } from "./stockSymbol.js"; -import { AppConstants } from "./appConstants.js"; +import { Portfolio, Stock } from "./stock"; +import { StockSymbol } from "./stockSymbol"; +import { AppConstants } from "./appConstants"; // floor0 は円未満を切り捨てる(資産配分はすべて円単位で行う)。 const floor0 = (x: Decimal): Decimal => x.toDecimalPlaces(0, Decimal.ROUND_DOWN); diff --git a/typescript/src/domain/appConstants.ts b/typescript/src/domain/appConstants.ts index abe1a63..269bc3b 100644 --- a/typescript/src/domain/appConstants.ts +++ b/typescript/src/domain/appConstants.ts @@ -1,6 +1,6 @@ import Decimal from "decimal.js"; -import { StockSymbol } from "./stockSymbol.js"; -import { Portfolio } from "./stock.js"; +import { StockSymbol } from "./stockSymbol"; +import { Portfolio } from "./stock"; /** アプリケーション定数。 */ export const AppConstants = { diff --git a/typescript/src/domain/stock.ts b/typescript/src/domain/stock.ts index d9f50b8..9d62cfa 100644 --- a/typescript/src/domain/stock.ts +++ b/typescript/src/domain/stock.ts @@ -1,5 +1,5 @@ import Decimal from "decimal.js"; -import { StockSymbol } from "./stockSymbol.js"; +import { StockSymbol } from "./stockSymbol"; // Stock は保有銘柄(銘柄と保有額)を表す。 export interface Stock { diff --git a/typescript/src/infrastructure/repository/accountRepositoryImpl.ts b/typescript/src/infrastructure/repository/accountRepositoryImpl.ts index b0ca86c..4c7770f 100644 --- a/typescript/src/infrastructure/repository/accountRepositoryImpl.ts +++ b/typescript/src/infrastructure/repository/accountRepositoryImpl.ts @@ -1,6 +1,6 @@ -import { AccountRepository } from "../../application/repository/accountRepository.js"; -import { Account } from "../../domain/account.js"; -import { UserId } from "../../domain/userId.js"; +import { AccountRepository } from "../../application/repository/accountRepository"; +import { Account } from "../../domain/account"; +import { UserId } from "../../domain/userId"; export class AccountRepositoryImpl implements AccountRepository { private readonly store: Map = new Map(); diff --git a/typescript/src/infrastructure/repository/portfolioRepositoryImpl.ts b/typescript/src/infrastructure/repository/portfolioRepositoryImpl.ts index ab9886c..7026440 100644 --- a/typescript/src/infrastructure/repository/portfolioRepositoryImpl.ts +++ b/typescript/src/infrastructure/repository/portfolioRepositoryImpl.ts @@ -1,6 +1,6 @@ -import { PortfolioRepository } from "../../application/repository/portfolioRepository.js"; -import { AppConstants } from "../../domain/appConstants.js"; -import { Portfolio } from "../../domain/stock.js"; +import { PortfolioRepository } from "../../application/repository/portfolioRepository"; +import { AppConstants } from "../../domain/appConstants"; +import { Portfolio } from "../../domain/stock"; export class PortfolioRepositoryImpl implements PortfolioRepository { private portfolio: Portfolio = AppConstants.initialPortfolio; diff --git a/typescript/src/infrastructure/server/dummyServer.ts b/typescript/src/infrastructure/server/dummyServer.ts index 03c619c..7110b86 100644 --- a/typescript/src/infrastructure/server/dummyServer.ts +++ b/typescript/src/infrastructure/server/dummyServer.ts @@ -1,14 +1,14 @@ -import { GetAssetUsecase } from "../../application/usecase/asset/getAssetUsecase.js"; -import { AdditionalBuyOrderUsecase } from "../../application/usecase/order/additionalBuyOrderUsecase.js"; -import { NewOrderUsecase } from "../../application/usecase/order/newOrderUsecase.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 { OrderController } from "../../presentation/orderController.js"; -import { PortfolioController } from "../../presentation/portfolioController.js"; -import { AccountRepositoryImpl } from "../repository/accountRepositoryImpl.js"; -import { PortfolioRepositoryImpl } from "../repository/portfolioRepositoryImpl.js"; +import { GetAssetUsecase } from "../../application/usecase/asset/getAssetUsecase"; +import { AdditionalBuyOrderUsecase } from "../../application/usecase/order/additionalBuyOrderUsecase"; +import { NewOrderUsecase } from "../../application/usecase/order/newOrderUsecase"; +import { RebalanceOrderUsecase } from "../../application/usecase/order/rebalanceOrderUsecase"; +import { GetLatestPortfolioUsecase } from "../../application/usecase/portfolio/getLatestPortfolioUsecase"; +import { UpdatePortfolioUsecase } from "../../application/usecase/portfolio/updatePortfolioUsecase"; +import { AssetController } from "../../presentation/assetController"; +import { OrderController } from "../../presentation/orderController"; +import { PortfolioController } from "../../presentation/portfolioController"; +import { AccountRepositoryImpl } from "../repository/accountRepositoryImpl"; +import { PortfolioRepositoryImpl } from "../repository/portfolioRepositoryImpl"; export class DummyServer { readonly assetController: AssetController; diff --git a/typescript/src/infrastructure/server/main.ts b/typescript/src/infrastructure/server/main.ts index 88b7e05..d3c5316 100644 --- a/typescript/src/infrastructure/server/main.ts +++ b/typescript/src/infrastructure/server/main.ts @@ -1,4 +1,4 @@ -import { DummyServer } from "./dummyServer.js"; +import { DummyServer } from "./dummyServer"; function main(): void { DummyServer.default(); diff --git a/typescript/src/presentation/assetController.ts b/typescript/src/presentation/assetController.ts index ff9ad9d..442d4bd 100644 --- a/typescript/src/presentation/assetController.ts +++ b/typescript/src/presentation/assetController.ts @@ -1,9 +1,9 @@ import { GetAssetUsecase, UserNotFoundException, -} from "../application/usecase/asset/getAssetUsecase.js"; -import { BadRequestException } from "./presentationException.js"; -import { parseUserId } from "./presentationPreparation.js"; +} from "../application/usecase/asset/getAssetUsecase"; +import { BadRequestException } from "./presentationException"; +import { parseUserId } from "./presentationPreparation"; export interface StockDto { symbol: string; diff --git a/typescript/src/presentation/orderController.ts b/typescript/src/presentation/orderController.ts index f0b7ddf..64934df 100644 --- a/typescript/src/presentation/orderController.ts +++ b/typescript/src/presentation/orderController.ts @@ -2,18 +2,18 @@ import { AdditionalBuyAmountTooSmallException, AdditionalBuyOrderUsecase, AdditionalBuyUserNotFoundException, -} from "../application/usecase/order/additionalBuyOrderUsecase.js"; +} from "../application/usecase/order/additionalBuyOrderUsecase"; import { NewOrderAmountTooSmallException, NewOrderUsecase, NewOrderUserAlreadyExistsException, -} from "../application/usecase/order/newOrderUsecase.js"; +} from "../application/usecase/order/newOrderUsecase"; import { RebalanceOrderUsecase, RebalanceUserNotFoundException, -} from "../application/usecase/order/rebalanceOrderUsecase.js"; -import { BadRequestException } from "./presentationException.js"; -import { parseAmount, parseUserId } from "./presentationPreparation.js"; +} from "../application/usecase/order/rebalanceOrderUsecase"; +import { BadRequestException } from "./presentationException"; +import { parseAmount, parseUserId } from "./presentationPreparation"; export interface NewOrderRequest { userId: string; diff --git a/typescript/src/presentation/portfolioController.ts b/typescript/src/presentation/portfolioController.ts index 6d4685c..ec737ff 100644 --- a/typescript/src/presentation/portfolioController.ts +++ b/typescript/src/presentation/portfolioController.ts @@ -1,14 +1,14 @@ import Decimal from "decimal.js"; import { GetLatestPortfolioUsecase, -} from "../application/usecase/portfolio/getLatestPortfolioUsecase.js"; +} from "../application/usecase/portfolio/getLatestPortfolioUsecase"; import { InvalidPortfolioException, UpdatePortfolioItemInput, UpdatePortfolioUsecase, -} from "../application/usecase/portfolio/updatePortfolioUsecase.js"; -import { StockSymbol } from "../domain/stockSymbol.js"; -import { BadRequestException } from "./presentationException.js"; +} from "../application/usecase/portfolio/updatePortfolioUsecase"; +import { StockSymbol } from "../domain/stockSymbol"; +import { BadRequestException } from "./presentationException"; export interface PortfolioItemDto { symbol: string; diff --git a/typescript/src/presentation/presentationPreparation.ts b/typescript/src/presentation/presentationPreparation.ts index 77b01cc..ef9c57c 100644 --- a/typescript/src/presentation/presentationPreparation.ts +++ b/typescript/src/presentation/presentationPreparation.ts @@ -1,6 +1,6 @@ import Decimal from "decimal.js"; -import { UserId } from "../domain/userId.js"; -import { BadRequestException } from "./presentationException.js"; +import { UserId } from "../domain/userId"; +import { BadRequestException } from "./presentationException"; export function parseUserId(s: string): UserId { try { diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json index 0f36fa8..c251c41 100644 --- a/typescript/tsconfig.json +++ b/typescript/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "ES2022", - "moduleResolution": "Bundler", + "moduleResolution": "node", "strict": true, "esModuleInterop": true, "skipLibCheck": true, From b6bc888b4e6ae3da48100181fd2fba014d5dfb5e Mon Sep 17 00:00:00 2001 From: krrrr38 Date: Sat, 20 Jun 2026 16:11:14 +0900 Subject: [PATCH 10/10] =?UTF-8?q?test(typescript):=20=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=B1=E3=83=BC=E3=82=B9=E5=90=8D=E3=81=8B=E3=82=89?= =?UTF-8?q?=E3=83=90=E3=82=B0=E3=81=AE=E8=A8=98=E8=BF=B0=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: krrrr38 --- typescript/tests/orderScenario.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/tests/orderScenario.test.ts b/typescript/tests/orderScenario.test.ts index 77af436..ea81bb3 100644 --- a/typescript/tests/orderScenario.test.ts +++ b/typescript/tests/orderScenario.test.ts @@ -64,7 +64,7 @@ describe("Investment Operation", () => { expect(new Decimal(asset2.cashAmount).equals("10000")).toBe(true); // 200000 - 76000 - 114000 }); - it("asset3: リバランス注文(課題2: 現金比率を確保しないバグがある)", async () => { + it("asset3: リバランス注文が正しく機能する", async () => { const userId = randomUUID(); await server.orderController.newOrder({ userId, amount: "100000" }); await server.orderController.additionalOrder({ userId, amount: "100000" });