diff --git a/package.json b/package.json index a00f5c2..3456191 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "melody-app", + "name": "melody-invest", "private": true, "workspaces": [ "server", @@ -11,7 +11,6 @@ "version": "0.0.2", "description": "", "author": "Baozier", - "license": "UNLICENSED", "engines": { "node": "^18.12.1", "npm": "^9.1.2", @@ -20,7 +19,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ValueMelody/melody-app" + "url": "git+https://github.com/ValueMelody/melody-invest" }, "scripts": { "shared": "npm run build --workspace=constants && npm run build --workspace=helpers", diff --git a/server/logics/evaluation.ts b/server/logics/evaluation.ts index 1054750..269834c 100644 --- a/server/logics/evaluation.ts +++ b/server/logics/evaluation.ts @@ -307,6 +307,8 @@ export const isIndicatorFitPatternBehaviors = ( movementBehaviors: interfaces.traderPatternModel.IndicatorMovementBehavior[], compareBehaviors: interfaces.traderPatternModel.indicatorCompareBehavior[], ): boolean => { + if (!indicatorInfo) return false + // Make sure every movement behaviors in one trader pattern fit current indicator const movementFit = movementBehaviors.every((behavior) => { const indicatorKey = IndicatorMovementTriggers[behavior] @@ -446,27 +448,32 @@ export const getTickerWithSellEvaluation = ( export const getTickerWithBuyEvaluation = ( tickerId: number, pattern: interfaces.traderPatternModel.Record, - dailyTicker: interfaces.dailyTickersModel.TickerInfo | null, + tickerInfo: interfaces.dailyTickersModel.TickerInfo | null, ): TickerWithEvaluation | null => { - if (!dailyTicker) return null - return null + // When no available info for this tickerId, then do not buy + if (!tickerInfo) return null - // const preferValue = getTickerPreferValue( - // pattern.buyPreference, dailyTicker.daily, dailyTicker.quarterly, dailyTicker.yearly, - // ) - // if (!preferValue && preferValue !== 0) return null + // Check current patterns buy preference if tickers are equal weight + const preferValue = getTickerEqualWeightPreferValue( + pattern.buyPreference, tickerInfo, + ) + if (preferValue === null) return null - // const weight = getTickerMovementWeights( - // pattern, - // dailyTicker.info, - // constants.Behavior.TickerMovementBuyBehaviors, - // ) + // Get current tickers buy weight + const weight = getTickerWeights( + pattern, + tickerInfo, + constants.Behavior.TickerMovementBuyBehaviors, + constants.Behavior.TickerCompareBuyBehaviors, + ) - // if (!weight) return null + if (!weight) return null - // return { - // tickerId, preferValue, weight, - // } + return { + tickerId, + preferValue, + weight, + } } export const getOrderedTickerEvaluations = ( @@ -485,51 +492,24 @@ export const getOrderedTickerEvaluations = ( }) } -export const getTickerBuyEaluations = ( +export const getTickerBuyEvaluations = ( tickerIds: number[], pattern: interfaces.traderPatternModel.Record, - dailyTickers: interfaces.dailyTickersModel.TickerInfos, + tickerInfos: interfaces.dailyTickersModel.TickerInfos, ) => { - const emptyEvaluations: TickerWithEvaluation[] = [] const tickerEvaluations = tickerIds.reduce((evaluations, tickerId) => { const evaluation = getTickerWithBuyEvaluation( - tickerId, pattern, dailyTickers[tickerId], + tickerId, pattern, tickerInfos[tickerId], ) if (evaluation) evaluations.push(evaluation) return evaluations - }, emptyEvaluations) + }, [] as TickerWithEvaluation[]) + // Order tickerEvaluations by weight and prefer value const orderedEvaluations = getOrderedTickerEvaluations(tickerEvaluations, pattern.buyPreference) return orderedEvaluations } -export const getIndicatorBuyMatches = ( - pattern: interfaces.traderPatternModel.Record, - indicatorInfo: interfaces.dailyIndicatorsModel.IndicatorInfo, -): boolean => { - const shouldBuy = getIndicatorMovementAndCompareMatches( - pattern, - indicatorInfo, - constants.Behavior.IndicatorMovementBuyBehaviors, - constants.Behavior.IndicatorCompareBuyBehaviors, - ) - return shouldBuy -} - -export const shouldSellBasedOnIndicator = ( - pattern: interfaces.traderPatternModel.Record, - indicatorInfo: interfaces.dailyIndicatorsModel.IndicatorInfo, -): boolean => { - // Should sell if indicator matches all pattern behaviors - const shouldSell = isIndicatorFitPatternBehaviors( - pattern, - indicatorInfo, - constants.Behavior.IndicatorMovementSellBehaviors, - constants.Behavior.IndicatorCompareSellBehaviors, - ) - return shouldSell -} - -export const getTickerSellEvalutions = ( +export const getTickerSellEvaluations = ( tickerIds: number[], pattern: interfaces.traderPatternModel.Record, tickerInfos: interfaces.dailyTickersModel.TickerInfos, @@ -542,7 +522,7 @@ export const getTickerSellEvalutions = ( if (evaluation) evaluations.push(evaluation) return evaluations }, [] as TickerWithEvaluation[]) - // Order tickerEvalutions by weight and prefer value + // Order tickerEvaluations by weight and prefer value const orderedEvaluations = getOrderedTickerEvaluations(tickerEvaluations, pattern.sellPreference) return orderedEvaluations } diff --git a/server/logics/transaction.ts b/server/logics/transaction.ts index bc8805b..4a77a7c 100644 --- a/server/logics/transaction.ts +++ b/server/logics/transaction.ts @@ -191,37 +191,46 @@ export const rebalanceHoldingDetail = ( } } -export const sellForHoldingPercent = ( +export const sellItemFromHolding = ( holdingDetail: interfaces.traderHoldingModel.Detail, itemForSell: interfaces.traderHoldingModel.Item, - tickerDaily: interfaces.tickerDailyModel.Record, + tickerInfo: interfaces.dailyTickersModel.TickerInfo, holdingSellPercent: number, tickerMinPercent: number, - maxCashValue: number, + cashMaxPercent: number, ): interfaces.traderHoldingModel.Detail | null => { - const sharesSold = Math.floor(itemForSell.shares * tickerDaily.splitMultiplier * holdingSellPercent / 100) - const baseSharesShold = holdingSellPercent === 100 ? itemForSell.shares : sharesSold / tickerDaily.splitMultiplier + // Get how many shares and base shares should be sold + const sharesSold = Math.floor(itemForSell.shares * tickerInfo.splitMultiplier * holdingSellPercent) + const baseSharesShold = holdingSellPercent === 1 ? itemForSell.shares : sharesSold / tickerInfo.splitMultiplier if (itemForSell.shares < baseSharesShold) return null - const valueSold = getItemHoldingValue(baseSharesShold, 0, tickerDaily) + const valueSold = sharesSold * tickerInfo.closePrice + + // If hold percentage is less than min required hold percentage, then do not sell const percentAfterSell = (itemForSell.value - valueSold) / holdingDetail.totalValue - if (percentAfterSell * 100 < tickerMinPercent) return null + if (percentAfterSell < tickerMinPercent) return null + // If case after sell is larger than maxmium allow cash, then do not sell const cashAfterSell = holdingDetail.totalCash + valueSold - if (cashAfterSell > maxCashValue) return null + const cashMaxValue = holdingDetail.totalValue * cashMaxPercent + if (cashAfterSell > cashMaxValue) return null const sharesAfterSell = itemForSell.shares - baseSharesShold - const valueAfterSell = sharesAfterSell * tickerDaily.closePrice * tickerDaily.splitMultiplier - const itemDetail = { - tickerId: itemForSell.tickerId, - shares: sharesAfterSell, - value: valueAfterSell, - splitMultiplier: tickerDaily.splitMultiplier, - } - const items = sharesAfterSell - ? holdingDetail.items.map((item) => item.tickerId === itemForSell.tickerId ? itemDetail : item) - : holdingDetail.items.filter((item) => item.tickerId !== itemForSell.tickerId) + let items + // If no shares left, then remove from holdings + if (sharesAfterSell) { + const valueAfterSell = sharesAfterSell * tickerInfo.closePrice * tickerInfo.splitMultiplier + const itemDetail = { + tickerId: itemForSell.tickerId, + shares: sharesAfterSell, + value: valueAfterSell, + splitMultiplier: tickerInfo.splitMultiplier, + } + items = holdingDetail.items.map((item) => item.tickerId === itemForSell.tickerId ? itemDetail : item) + } else { + items = holdingDetail.items.filter((item) => item.tickerId !== itemForSell.tickerId) + } return { date: '', @@ -231,63 +240,66 @@ export const sellForHoldingPercent = ( } } -export const detailAfterSell = ( +export const getHoldingDetailAfterSell = ( currentDetail: interfaces.traderHoldingModel.Detail, sellTickerIds: number[], - dailyTickers: interfaces.dailyTickersModel.TickerInfos, + tickerInfos: interfaces.dailyTickersModel.TickerInfos, holdingSellPercent: number, tickerMinPercent: number, - maxCashValue: number, + cashMaxPercent: number, ): DetailAndTransaction => { - // let hasTransaction = false + let hasTransaction = false const holdingDetail = sellTickerIds.reduce((details, tickerId) => { - return details + const tickerInfo = tickerInfos[tickerId] + const item = details.items.find((item) => item.tickerId === tickerId) + // Make sure holding and ticker info exists + if (!tickerInfo || !item) return details + + const refreshedDetails = sellItemFromHolding( + details, + item, + tickerInfo, + holdingSellPercent, + tickerMinPercent, + cashMaxPercent, + ) + // If nothing sold, keep details the same + if (!refreshedDetails) return details - // const matchedDaily = dailyTickers[tickerId]?.daily - // const item = details.items.find((item) => item.tickerId === tickerId) - // if (!matchedDaily || !item) return details - - // const refreshed = sellForHoldingPercent( - // details, - // item, - // matchedDaily, - // holdingSellPercent, - // tickerMinPercent, - // maxCashValue, - // ) - // if (!refreshed) return details - - // hasTransaction = true - // return refreshed + hasTransaction = true + return refreshedDetails }, currentDetail) return { - hasTransaction: false, + hasTransaction, holdingDetail, } } -export const buyForHoldingPercent = ( +export const buyItemToHolding = ( holdingDetail: interfaces.traderHoldingModel.Detail, itemForBuy: interfaces.traderHoldingModel.Item, - tickerDaily: interfaces.tickerDailyModel.Record, - maxBuyAmount: number, + tickerInfo: interfaces.dailyTickersModel.TickerInfo, + holdingBuyPercent: number, tickerMaxPercent: number, ): interfaces.traderHoldingModel.Detail | null => { - const isNewHolding = !itemForBuy.shares + const maxBuyAmount = holdingDetail.totalValue & holdingBuyPercent + + // Use maximum allowed cash or use total cash left if less than maximun allowed const maxCashToUse = holdingDetail.totalCash < maxBuyAmount ? holdingDetail.totalCash : maxBuyAmount - const sharesBought = Math.floor(maxCashToUse / tickerDaily.closePrice) - if (!sharesBought) return null - const baseSharesBought = sharesBought / tickerDaily.splitMultiplier - const adjustedPrice = tickerDaily.closePrice * tickerDaily.splitMultiplier - const valueBought = baseSharesBought * adjustedPrice + const sharesBought = Math.floor(maxCashToUse / tickerInfo.closePrice) + if (!sharesBought) return null + const baseSharesBought = sharesBought / tickerInfo.splitMultiplier + const valueBought = sharesBought * tickerInfo.closePrice const sharesAfterBuy = baseSharesBought + itemForBuy.shares - const valueAfterBuy = sharesAfterBuy * adjustedPrice - const percentAfterBuy = (valueAfterBuy / holdingDetail.totalValue) * 100 + const valueAfterBuy = sharesAfterBuy * tickerInfo.closePrice * tickerInfo.splitMultiplier + + // If hold more than maxmum allowed percentage after buy, then do not buy + const percentAfterBuy = valueAfterBuy / holdingDetail.totalValue const isLessThanMaxPercent = percentAfterBuy <= tickerMaxPercent if (!isLessThanMaxPercent) return null @@ -295,12 +307,14 @@ export const buyForHoldingPercent = ( tickerId: itemForBuy.tickerId, shares: sharesAfterBuy, value: valueAfterBuy, - splitMultiplier: tickerDaily.splitMultiplier, + splitMultiplier: tickerInfo.splitMultiplier, } + const isNewHolding = !itemForBuy.shares const items = isNewHolding ? [...holdingDetail.items, itemDetail] : holdingDetail.items.map((item) => item.tickerId === itemForBuy.tickerId ? itemDetail : item) + return { totalValue: holdingDetail.totalValue, totalCash: holdingDetail.totalCash - valueBought, @@ -309,37 +323,41 @@ export const buyForHoldingPercent = ( } } -export const detailAfterBuy = ( +export const getHoldingDetailAfterBuy = ( currentDetail: interfaces.traderHoldingModel.Detail, buyTickerIds: number[], - dailyTickers: interfaces.dailyTickersModel.TickerInfos, - maxBuyAmount: number, + tickerInfos: interfaces.dailyTickersModel.TickerInfos, + holdingBuyPercent: number, tickerMaxPercent: number, ): DetailAndTransaction => { - // let hasTransaction = false - const holdingDetail = buyTickerIds.reduce((detail, tickerId) => { - return detail - // const matchedDaily = dailyTickers[tickerId]?.daily - // if (!matchedDaily) return detail - - // const item = currentDetail.items.find((item) => item.tickerId === tickerId) || - // { tickerId, shares: 0, splitMultiplier: 0, value: 0 } - - // const refreshed = buyForHoldingPercent( - // detail, - // item, - // matchedDaily, - // maxBuyAmount, - // tickerMaxPercent, - // ) - // if (!refreshed) return detail - - // hasTransaction = true - // return refreshed + let hasTransaction = false + const holdingDetail = buyTickerIds.reduce((details, tickerId) => { + const tickerInfo = tickerInfos[tickerId] + // Make sure holding and ticker info exists + if (!tickerInfo) return details + + // Find existing holding item or initial an empty one + const item = details.items.find((item) => item.tickerId === tickerId) || { + tickerId, shares: 0, splitMultiplier: 0, value: 0 + } + + const refreshedDetails = buyItemToHolding( + details, + item, + tickerInfo, + holdingBuyPercent, + tickerMaxPercent, + ) + + // If nothing bought, keep details the same + if (!refreshedDetails) return details + + hasTransaction = true + return refreshedDetails }, currentDetail) return { holdingDetail, - hasTransaction: false, + hasTransaction, } } diff --git a/server/services/calcTraders.ts b/server/services/calcTraders.ts index 569ddb8..cbdca69 100644 --- a/server/services/calcTraders.ts +++ b/server/services/calcTraders.ts @@ -127,8 +127,8 @@ const calcTraderPerformance = async ( const cashMaxPercent = pattern.cashMaxPercent / 100 const tickerMinPercent = pattern.tickerMinPercent / 100 const tickerMaxPercent = pattern.tickerMaxPercent / 100 - const holdingBuyPercent = pattern.holdingBuyPercent - const holdingSellPercent = pattern.holdingSellPercent + const holdingBuyPercent = pattern.holdingBuyPercent / 100 + const holdingSellPercent = pattern.holdingSellPercent / 100 console.info(`Checking Trader:${trader.id}`) const transaction = await databaseAdapter.createTransaction() @@ -185,60 +185,72 @@ const calcTraderPerformance = async ( ) // Check if indicatorInfo matches sell criterion - const shouldSellBasedOnIndicator = !!indicatorInfo && - evaluationLogic.shouldSellBasedOnIndicator(pattern, indicatorInfo) + const shouldSellBasedOnIndicator = evaluationLogic.isIndicatorFitPatternBehaviors( + pattern, + indicatorInfo, + constants.Behavior.IndicatorMovementSellBehaviors, + constants.Behavior.IndicatorCompareSellBehaviors, + ) // Get a list of ordered tickerIds that should be sold const holdingTickerIds = rebalancedDetail.items.map((item) => item.tickerId) + + // Get evaluations of each ticker, order by which one should be sold first const tickerSellEvaluations = shouldSellBasedOnIndicator - ? evaluationLogic.getTickerSellEvalutions( + ? evaluationLogic.getTickerSellEvaluations( holdingTickerIds, pattern, tickerInfos, ) : [] const sellTickerIds = tickerSellEvaluations.map((tickerSellEvaluation) => tickerSellEvaluation.tickerId) + // Update holding after sell target tickers const { holdingDetail: detailAfterSell, hasTransaction: hasSellTransaction, - } = transactionLogic.detailAfterSell( - detailAfterRebalance, + } = transactionLogic.getHoldingDetailAfterSell( + rebalancedDetail, sellTickerIds, - availableTargets, + availableTickerInfos, holdingSellPercent, tickerMinPercent, - maxCashValue, + cashMaxPercent, ) - const isBuyIndicatorMatches = !!indicatorInfo && evaluationLogic.getIndicatorBuyMatches(pattern, indicatorInfo) - const buyTickerEvaluations = isBuyIndicatorMatches - ? evaluationLogic.getTickerBuyEaluations( - Object.keys(availableTargets).map((id) => parseInt(id)), - pattern, - availableTargets, + // Check if indicatorInfo matches buy criterion + const shouldBuyBasedOnIndicator = evaluationLogic.isIndicatorFitPatternBehaviors( + pattern, + indicatorInfo, + constants.Behavior.IndicatorMovementBuyBehaviors, + constants.Behavior.IndicatorCompareBuyBehaviors, + ) + + // Get a list of tickerIds that could be trade + const availableTickerIds = Object.keys(availableTickerInfos).map((id) => parseInt(id)) + + // Get evaluations of each ticker, order by which one should be bought first + const tickerBuyEvaluations = shouldBuyBasedOnIndicator + ? evaluationLogic.getTickerBuyEvaluations( + availableTickerIds, pattern, tickerInfos, ) : [] - const buyTickerIds = buyTickerEvaluations.map((evaluation) => evaluation.tickerId) + const buyTickerIds = tickerBuyEvaluations.map((evaluation) => evaluation.tickerId) - const maxBuyAmount = detailAfterSell.totalValue * holdingBuyPercent / 100 + // Update holding after buy target tickers const { holdingDetail: detailAfterBuy, hasTransaction: hasBuyTransaction, - } = transactionLogic.detailAfterBuy( + } = transactionLogic.getHoldingDetailAfterBuy( detailAfterSell, buyTickerIds, - availableTargets, - maxBuyAmount, + tickerInfos, + holdingBuyPercent, tickerMaxPercent, ) - if (shouldRebalance && hasRebalanceTransaction) { - rebalancedAt = tradeDate - hasRebalanced = true - } - const hasTransaction = hasRebalanceTransaction || hasSellTransaction || hasBuyTransaction + if (hasTransaction) { - hasCreatedAnyRecord = true + shouldCommitTransaction = true if (!startedAt) startedAt = tradeDate holding = await traderHoldingModel.create({ traderId: trader.id, @@ -249,10 +261,15 @@ const calcTraderPerformance = async ( }, transaction) } + if (shouldRebalance && hasRebalanceTransaction) { + rebalancedAt = tradeDate + hasRebalanced = true + } + tradeDate = nextDate } - if (hasCreatedAnyRecord) { + if (shouldCommitTransaction) { await transaction.commit() } else { await transaction.rollback() @@ -264,12 +281,14 @@ const calcTraderPerformance = async ( const traderTransaction = await databaseAdapter.createTransaction() try { + // If not holding created or there is no start date, then save estimate date only if (!holding || !startedAt) { await traderModel.update(trader.id, { estimatedAt: latestDate }, traderTransaction) await traderTransaction.commit() return } + // Regenerate tickerHolder records for current trader await tickerHolderModel.destroyTraderTickers(trader.id, traderTransaction) await runTool.asyncForEach(holding.items, async ( item: interfaces.traderHoldingModel.Item,