diff --git a/src/components/organisms/Navbar.tsx b/src/components/organisms/Navbar.tsx index b7816de..1bd9991 100644 --- a/src/components/organisms/Navbar.tsx +++ b/src/components/organisms/Navbar.tsx @@ -1,14 +1,13 @@ import { signInWithGoogle } from "@jaws/auth/firestoreAuth"; import { getToday } from "@jaws/lib/helpers"; -import { AccountContext } from "@jaws/store/account/accountContext"; +import { useAccountStore } from "@jaws/store/account/accountContext"; import { User } from "@jaws/store/account/accountStore"; import { Theme } from "@jaws/styles/themes"; import { setCookies } from "cookies-next"; import Link from "next/link"; import { useRouter } from "next/router"; -import { useContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import styled, { css } from "styled-components"; -import { useStore } from "zustand"; import Button from "../atoms/buttons/Button"; const NavBarContainer = styled.div` @@ -44,15 +43,17 @@ const LinksContainer = styled.div` gap: 15px; `; -const NavBarItem = styled.div` +const NavBarItem = styled.a` display: flex; align-items: center; justify-content: center; height: 100%; cursor: pointer; + text-decoration: none; :hover { color: ${({ theme }) => theme.palette.actionHover.text}; transform: scale(1.05); + text-decoration: none; } ${({ active }: { active: boolean }) => active && @@ -74,10 +75,7 @@ const Navbar = () => { setPathName(router.pathname); }, [router]); - const store = useContext(AccountContext); - if (!store) throw new Error("Missing AccountContext.Provider in the tree"); - const [isLoggedIn, setIsLoggedIn, setUser, logoutUser] = useStore( - store, + const [isLoggedIn, setIsLoggedIn, setUser, logoutUser] = useAccountStore( (state) => [ state.isLoggedIn, state.setIsLoggedIn, @@ -103,21 +101,15 @@ const Navbar = () => { - - + + Todays run - + All orders - + Portfolio diff --git a/src/components/organisms/WidgetGrid.tsx b/src/components/organisms/WidgetGrid.tsx index 600b5d4..02957d5 100644 --- a/src/components/organisms/WidgetGrid.tsx +++ b/src/components/organisms/WidgetGrid.tsx @@ -3,7 +3,7 @@ import styled from "styled-components"; const GridContainer = styled.div` display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, 1fr); gap: 10px; `; diff --git a/src/db/dailyStatsEntity.ts b/src/db/dailyStatsEntity.ts new file mode 100644 index 0000000..dbd21f4 --- /dev/null +++ b/src/db/dailyStatsEntity.ts @@ -0,0 +1,34 @@ +import { db } from "@jaws/services/firestoreService"; +import { DailyStats } from "./dailyStatsMeta"; +export async function getDailyStats({ + startDate, + endDate, + accountId, +}: { + /** Format: YYYY-MM-DD (inclusive) */ + startDate: string; + /** Format: YYYY-MM-DD (inclusive) */ + endDate: string; + accountId: string; +}) { + return ( + await db + .collection("daily-stats") + .where("accountId", "==", accountId) + .where("date", ">=", startDate) + .where("date", "<=", endDate) + .orderBy("date", "asc") + .get() + ).docs.map((doc) => doc.data()) as DailyStats[]; +} + +export async function upsertDailyStats(stats: DailyStats) { + return db + .collection("daily-stats") + .doc(generateDocId(stats.accountId, stats.date)) + .set(stats); +} + +function generateDocId(accountId: string, date: string) { + return `${date.replace(/-/g, "")}-${accountId}`; +} diff --git a/src/db/dailyStatsMeta.ts b/src/db/dailyStatsMeta.ts new file mode 100644 index 0000000..7370a40 --- /dev/null +++ b/src/db/dailyStatsMeta.ts @@ -0,0 +1,11 @@ +export interface DailyStats { + accountId: string; + nav: number; + shares: number; + date: string; + /** + * TODO: Remove when not needed anymore :) + * @deprecated don't rely on this for anything + */ + debugInfo?: Record; +} diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index a6fdca7..aae8e45 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -8,6 +8,21 @@ function addZero(value: number) { return value < 10 ? `0${value}` : value; } +export const getDateString = ({ + date, + withDashes, +}: { + date: Date; + withDashes: boolean; +}) => { + const day = date.getDate(); + const month = date.getMonth() + 1; + + return [date.getFullYear(), addZero(month), addZero(day)].join( + withDashes ? "-" : "", + ); +}; + export const getToday = () => { const now = new Date(); const nowDate = now.getDate(); diff --git a/src/lib/hooks/useGetTableData.ts b/src/lib/hooks/useGetTableData.ts index ebfc0b6..ce41585 100644 --- a/src/lib/hooks/useGetTableData.ts +++ b/src/lib/hooks/useGetTableData.ts @@ -2,6 +2,7 @@ import { ExtendedTradesDataType, TRADE_STATUS } from "@jaws/db/tradesMeta"; import { getAccountAssets, getAccountCashBalance, + getAccountEquity, getJawsPortfolio, getMovingAverages, } from "@jaws/services/backendService"; @@ -35,6 +36,7 @@ export const useGetTableData = () => { investedValue: number; marketValue: number; totalPortfolioValue: number; + cashBalance: number; }>({} as any); useEffect(() => { @@ -42,8 +44,9 @@ export const useGetTableData = () => { getAccountAssets(), getAccountCashBalance(), getJawsPortfolio(), + getAccountEquity(), ]) - .then(async ([assetsResult, balance, trades]) => { + .then(async ([assetsResult, cashBalance, trades, equity]) => { const assets = assetsResult.assets; const movingAverages = await getMovingAverages( @@ -62,12 +65,16 @@ export const useGetTableData = () => { }[]; const tableData = convertToTableData({ - assets, - balance, + equity, data: sortedData, }); - setData(tableData); + setData({ + assets: tableData, + cashBalance, + totalPortfolioValue: equity, + ...calculateAssetValues(assets), + }); setFetchStatus("ok"); }) .catch(console.error); @@ -77,36 +84,16 @@ export const useGetTableData = () => { }; function convertToTableData({ - assets, - balance, + equity, data, }: { - assets: RawPosition[]; - balance: number; + equity: number; data: { trade: ExtendedTradesDataType; movingAvg: number; alpacaAsset: RawPosition; }[]; -}): { - marketValue: number; - investedValue: number; - assets: PortfolioTableAsset[]; - totalPortfolioValue: number; -} { - const investedValue = assets.reduce( - (sum: number, { cost_basis }) => sum + parseFloat(cost_basis), - 0, - ); - - const marketValue: number = assets.reduce( - (sum: number, { market_value }) => - sum + (market_value ? parseFloat(market_value) : 0), - 0, - ); - - const totalPortfolioValue = balance + marketValue; - +}): PortfolioTableAsset[] { const extendedAssets: PortfolioTableAsset[] = data.map( ({ trade, alpacaAsset, movingAvg }) => { tradeHasRequiredData(trade); @@ -123,7 +110,7 @@ function convertToTableData({ trade, currentPrice, movingAvg, - totalAssets: totalPortfolioValue, + totalAssets: equity, }); const stopLossType = [ @@ -134,8 +121,7 @@ function convertToTableData({ return { ...trade, - percentOfTotalAssets: - ((avgEntryPrice * trade.quantity) / totalPortfolioValue) * 100, + percentOfTotalAssets: ((avgEntryPrice * trade.quantity) / equity) * 100, changeSinceEntry: (currentPrice - avgEntryPrice) / avgEntryPrice, value: trade.quantity * currentPrice, currentPrice, @@ -151,10 +137,23 @@ function convertToTableData({ }, ); - return { - investedValue, - marketValue, - assets: extendedAssets, - totalPortfolioValue, - }; + return extendedAssets; +} + +function calculateAssetValues(assets: RawPosition[]): { + investedValue: number; + marketValue: number; +} { + const investedValue = assets.reduce( + (sum: number, { cost_basis }) => sum + parseFloat(cost_basis), + 0, + ); + + const marketValue: number = assets.reduce( + (sum: number, { market_value }) => + sum + (market_value ? parseFloat(market_value) : 0), + 0, + ); + + return { investedValue, marketValue }; } diff --git a/src/lib/navHandler.ts b/src/lib/navHandler.ts new file mode 100644 index 0000000..47d10d6 --- /dev/null +++ b/src/lib/navHandler.ts @@ -0,0 +1,80 @@ +import { getDailyStats, upsertDailyStats } from "@jaws/db/dailyStatsEntity"; +import { RawActivity } from "@jaws/services/alpacaMeta"; +import * as alpacaService from "@jaws/services/alpacaService"; +import { calculateNAV } from "@jaws/util/calculateNAV"; +import { getDateString, getTodayWithDashes, ONE_DAY_IN_MS } from "./helpers"; + +export type DayDateString = ``; + +export const calculateTodaysNAV = async (accountId: string) => { + const todayDate = getTodayWithDashes(); + const yesterdaysDate = getDateString({ + date: new Date(Number(new Date(todayDate)) - ONE_DAY_IN_MS), + withDashes: true, + }); + + const [equity, cashActivities, [{ debugInfo, ...yesterdayStats }]] = + await Promise.all([ + alpacaService.getEquity(), + alpacaService.getAccountActivities({ + activity_type: "TRANS", + date: todayDate, + }), + getDailyStats({ + startDate: yesterdaysDate, + endDate: yesterdaysDate, + accountId, + }), + ]); + + isNonTradeActivities(cashActivities); + + const netDeposits = cashActivities + .filter((ct) => ct.status !== "canceled") + .reduce((sum, ct) => sum + parseFloat(ct.net_amount), 0); + + const NAV = calculateNAV({ + numShares: yesterdayStats.shares, + equity: parseFloat(equity), + netDeposits, + }); + + return { + equity, + cashActivities, + netDeposits, + yesterdayStats, + ...NAV, + todayDate, + yesterdaysDate, + }; +}; + +export const saveTodaysNAV = async () => { + // TODO: Do for all accounts + const { + NAV: nav, + todayDate: date, + newNumShares: shares, + ...debugInfo + } = await calculateTodaysNAV("hejare"); + + await upsertDailyStats({ + nav, + accountId: "hejare", + date, + shares, + /** TODO: Remove when not needed anymore :) */ + debugInfo, + }); + + return { nav, date, shares, ...debugInfo }; +}; + +function isNonTradeActivities( + activities: RawActivity[], +): asserts activities is (RawActivity & { net_amount: string })[] { + if (!activities.every((a) => "net_amount" in a)) { + throw new TypeError("Found activity without net_amount"); + } +} diff --git a/src/lib/tradesHandler.ts b/src/lib/tradesHandler.ts index b8a2cb0..e476126 100644 --- a/src/lib/tradesHandler.ts +++ b/src/lib/tradesHandler.ts @@ -376,7 +376,7 @@ export const triggerStopLossTakeProfit = async () => { const [newFilledTrades, balance] = await Promise.all([ populateTradesData(filledTrades), - alpacaService.getPortfolioValue(), + alpacaService.getEquity(), ]); return performActions(newFilledTrades, parseFloat(balance)); diff --git a/src/pages/api/broker/account/history.ts b/src/pages/api/broker/account/history.ts new file mode 100644 index 0000000..362552d --- /dev/null +++ b/src/pages/api/broker/account/history.ts @@ -0,0 +1,43 @@ +import { ResponseDataType } from "@jaws/pages/api/ResponseDataMeta"; +import { getAccountHistory } from "@jaws/services/alpacaService"; +import { GetPortfolioHistory, PortfolioHistory } from "@master-chief/alpaca"; +import { NextApiRequest, NextApiResponse } from "next"; + +export interface PortfolioHistoryResponse extends ResponseDataType { + history: Omit & { timestamp: string[] }; +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const params = req.query as GetPortfolioHistory; + + const responseData: Partial = { + status: "INIT", + }; + try { + await getAccountHistory(params) + .then((result) => { + responseData.status = "OK"; + responseData.history = result; + }) + .catch((e) => { + responseData.status = "NOK"; + responseData.message = e.message; + }); + res.status(200).json(responseData); + } catch (e) { + let message; + if (e instanceof Error) { + message = e.message; + if (typeof e.message !== "string") { + message = e; + } + } + console.error(message); + return res.status(500).json({ + error: message, + }); + } +} diff --git a/src/pages/api/data/daily-stats/index.ts b/src/pages/api/data/daily-stats/index.ts new file mode 100644 index 0000000..8e98be4 --- /dev/null +++ b/src/pages/api/data/daily-stats/index.ts @@ -0,0 +1,87 @@ +import { getDailyStats } from "@jaws/db/dailyStatsEntity"; +import { getTodayWithDashes } from "@jaws/lib/helpers"; +import { NextApiRequest, NextApiResponse } from "next"; +import { ResponseDataType } from "../../ResponseDataMeta"; + +interface DailyStatsResponseData { + nav: number; + date: string; +} + +export interface DailyStatsResponse extends ResponseDataType { + data?: DailyStatsResponseData | DailyStatsResponseData[]; +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const response: DailyStatsResponse = { status: "INIT" }; + + // TODO: get from middleware + const accountId = "hejare"; + + try { + if (req.method !== "GET") { + throw new Error(`Method not supported: ${req.method || "none"}`); + } + + const dates = getValidDateRange(req.query); + + if (dates) { + response.data = await getStats({ ...dates, accountId }); + } else { + response.data = await getStats( + { + startDate: getTodayWithDashes(), + endDate: getTodayWithDashes(), + accountId, + }, + true, + ); + } + + response.status = "OK"; + return res.status(200).json(response); + + // const nav = + } catch (error: any) { + res.status(500).json({ status: "NOK", message: error.message || error }); + } +} + +async function getStats( + params: { + startDate: string; + endDate: string; + accountId: string; + }, + justOne?: boolean, +) { + const stats = (await getDailyStats(params)).map(({ nav, date }) => ({ + nav, + date, + })); + return justOne ? stats[0] : stats; +} + +function getValidDateRange(dates: { + startDate?: string; + endDate?: string; +}): { startDate: string; endDate: string } | undefined { + if ( + typeof dates.startDate === "string" || + typeof dates.endDate === "string" + ) { + if ( + typeof dates.startDate !== "string" || + typeof dates.endDate !== "string" + ) { + throw new Error("Need two dates for date range"); + } else { + return { ...dates } as { startDate: string; endDate: string }; + } + } + + return; +} diff --git a/src/pages/api/triggers/trigger-calculate-daily-stats.ts b/src/pages/api/triggers/trigger-calculate-daily-stats.ts new file mode 100644 index 0000000..29c1815 --- /dev/null +++ b/src/pages/api/triggers/trigger-calculate-daily-stats.ts @@ -0,0 +1,16 @@ +import { saveTodaysNAV } from "@jaws/lib/navHandler"; +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function (req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") { + res.status(400).json({ error: "Unsupported method" }); + return; + } + + try { + const data = await saveTodaysNAV(); + return res.status(200).json(data); + } catch (e) { + return res.status(500).json({ error: JSON.stringify(e) }); + } +} diff --git a/src/pages/portfolio.tsx b/src/pages/portfolio.tsx index 526fa05..2a3874f 100644 --- a/src/pages/portfolio.tsx +++ b/src/pages/portfolio.tsx @@ -17,6 +17,7 @@ const PortfolioPage: NextPage = () => { investedValue, marketValue, totalPortfolioValue, + cashBalance, } = useGetTableData(); if (fetchStatus !== "ok") { @@ -50,16 +51,22 @@ const PortfolioPage: NextPage = () => { } > - Profit/loss: + Invest. P/L: - Portfolio + Equity ${totalPortfolioValue.toFixed()} + + + Cash + ${cashBalance.toFixed()} + + diff --git a/src/services/alpacaMeta.ts b/src/services/alpacaMeta.ts index 18a83bd..c19ec5c 100644 --- a/src/services/alpacaMeta.ts +++ b/src/services/alpacaMeta.ts @@ -1,3 +1,5 @@ +import { RawActivity as AlpacaRawActivity } from "@master-chief/alpaca/@types/entities"; + export enum SUMMED_ORDER_STATUS { FILLED = "FILLED", IN_PROGRESS = "IN_PROGRESS", @@ -81,3 +83,8 @@ export const orderStatusesInProgress = [ AlpacaOrderStatusType.PENDING_CANCEL, AlpacaOrderStatusType.PENDING_REPLACE, ]; + +export type RawActivity = AlpacaRawActivity & { + status: "executed" | "correct" | "canceled"; + net_amount?: string; +}; diff --git a/src/services/alpacaService.ts b/src/services/alpacaService.ts index 4a8f914..c6af2cb 100644 --- a/src/services/alpacaService.ts +++ b/src/services/alpacaService.ts @@ -1,17 +1,20 @@ import { getISOStringForToday, isValidSymbol } from "@jaws/lib/helpers"; import { handleResult } from "@jaws/util"; import { handleLimitPrice } from "@jaws/util/handleLimitPrice"; +import { GetPortfolioHistory } from "@master-chief/alpaca"; import { + PortfolioHistory, RawAccount, RawOrder, RawPosition, } from "@master-chief/alpaca/@types/entities"; import { + GetAccountActivities, GetOrders as AlpacaGetOrdersParams, PlaceOrder, } from "@master-chief/alpaca/@types/params"; import fetch, { BodyInit, RequestInit } from "node-fetch"; -import { Side } from "./alpacaMeta"; +import { RawActivity, Side } from "./alpacaMeta"; const { ALPACA_API_KEY_ID = "[NOT_DEFINED_IN_ENV]", @@ -269,7 +272,7 @@ export const getAccountAssets = async () => { }; /* The total balance (cash balance + assets value) */ -export const getPortfolioValue = async () => { +export const getEquity = async () => { const result = await getAccount(); return result.equity; }; @@ -280,6 +283,30 @@ export async function getAccount() { ); } +export async function getAccountHistory(opts?: GetPortfolioHistory) { + const params = new URLSearchParams(opts as Record); + + const res = await sendAlpacaRequest( + `trading/accounts/${accountId}/account/portfolio/history?${params.toString()}`, + ); + + return { + ...res, + timestamp: res.timestamp.map((t) => new Date(t * 1000).toISOString()), + }; +} + +export async function getAccountActivities({ + activity_type, + ...params +}: GetAccountActivities = {}) { + const searchParams = new URLSearchParams(params as Record); + + return sendAlpacaRequest( + `accounts/activities/${activity_type || ""}?${searchParams.toString()}`, + ); +} + async function sendAlpacaRequest(path: string, options?: RequestInit) { const res = await fetch(`${brokerApiBaseUrl}/${path}`, { ...options, diff --git a/src/services/backendService.ts b/src/services/backendService.ts index 09968c1..0f0444b 100644 --- a/src/services/backendService.ts +++ b/src/services/backendService.ts @@ -1,9 +1,12 @@ import { BrokerAccountAssetsResponse } from "@jaws/api/broker/account/assets"; import { BrokerAccountBalanceResponse } from "@jaws/api/broker/account/balance"; +import { BrokerAccountEquityResponse } from "@jaws/api/broker/account/equity"; +import { PortfolioHistoryResponse } from "@jaws/api/broker/account/history"; +import { DailyStatsResponse } from "@jaws/api/data/daily-stats"; import { ExtendedTradesDataType, TRADE_STATUS } from "@jaws/db/tradesMeta"; import { getToday } from "@jaws/lib/helpers"; -import { BrokerAccountEquityResponse } from "@jaws/pages/api/broker/account/equity"; import { convertResult, handleResult } from "@jaws/util"; +import { GetPortfolioHistory } from "@master-chief/alpaca"; import { RawOrder } from "@master-chief/alpaca/@types/entities"; import fetch from "node-fetch"; @@ -102,8 +105,28 @@ export const getAccountEquity = async () => { return parseFloat(res.equity); }; +export const getPortfolioHistory = async (opts?: GetPortfolioHistory) => { + const params = new URLSearchParams(opts as Record); + + const resp = await fetch(`/api/broker/account/history?${params.toString()}`); + const res = await handleResult(resp); + + return res.history; +}; + export const getOrders = () => { return fetch("/api/broker/orders").then((res) => handleResult<{ orders: RawOrder[] }>(res), ); }; + +export const getDailyStats = async (dates: { + startDate: string; + endDate: string; +}): Promise => { + const response = await fetch( + "api/data/daily-stats?" + new URLSearchParams(dates).toString(), + ).then((res) => handleResult(res)); + + return response.data; +}; diff --git a/src/util/calculateNAV.test.ts b/src/util/calculateNAV.test.ts new file mode 100644 index 0000000..cac2264 --- /dev/null +++ b/src/util/calculateNAV.test.ts @@ -0,0 +1,88 @@ +import { calculateNAV } from "./calculateNAV"; + +describe("calculateNAV", () => { + it("Calculates NAV with 0 cash flow", () => { + const equity = 52845; + const numShares = 535.2; + const cashFlow = 0; + + const { NAV, newNumShares } = calculateNAV({ + equity, + numShares, + netDeposits: cashFlow, + }); + + expect(NAV).toBeCloseTo(98.74); + expect(newNumShares).toBe(numShares); + }); + + it("Calculates NAV with positive cash flow", () => { + const equity = 53845; + const numShares = 535.2; + const cashFlow = 1000; + + const { NAV, newNumShares } = calculateNAV({ + equity, + numShares, + netDeposits: cashFlow, + }); + + expect(NAV).toBeCloseTo(98.74); + expect(newNumShares).toBeCloseTo(545.33); + }); + + it("Calculates NAV with negative cash flow", () => { + const equity = 51845; + const numShares = 535.2; + const cashFlow = -1000; + + const { NAV, newNumShares } = calculateNAV({ + equity, + numShares, + netDeposits: cashFlow, + }); + + expect(NAV).toBeCloseTo(98.74); + expect(newNumShares).toBeCloseTo(525.07); + }); + + it("Calculates correctly from day to day", () => { + const equities = [50000, 52000]; // 4% increase + const startNumShares = 100; + const cashFlowDay2 = 1000; // half of increase is due to cash flow + + const { NAV: NAV1 } = calculateNAV({ + equity: equities[0], + numShares: startNumShares, + netDeposits: 0, + }); + + expect(NAV1).toEqual(500); + + const { NAV: NAV2, newNumShares } = calculateNAV({ + equity: equities[1], + numShares: startNumShares, + netDeposits: cashFlowDay2, + }); + + expect(NAV2 / NAV1).toEqual(1.02); // 2% increase in NAV + + // Deposits means buying or selling at the "new" NAV price, in this + // case when the equity was 51000. We should have created as many + // new shares as can be bought for $1000 with the new price: + // + // newly_created_shares + // = 1000 (deposit) / (51000 (eq) / 100 (shares)) + // = 1000 / 510 + // = 1.96 + // + // new_num_shares = start_num_shares + 1.96 + // = 100 + 1.96 + // = 101.96 + // + // new_nav = 52000 (new eq) / 101.96 (new number of shares) + // = 510 + expect(newNumShares).toBeCloseTo(101.96); + expect(NAV2).toBeCloseTo(510); + }); +}); diff --git a/src/util/calculateNAV.ts b/src/util/calculateNAV.ts new file mode 100644 index 0000000..a20d4f9 --- /dev/null +++ b/src/util/calculateNAV.ts @@ -0,0 +1,35 @@ +export function calculateNAV({ + numShares, + equity, + netDeposits, +}: { + /** + * Number of shares outsanding (of Jaws account) + */ + numShares: number; + /** + * Cash + market value + */ + equity: number; + /** + * How much cash has been added (or withdrawn) since the last NAV + * calculation. This cash flow should already be included in `equity` + */ + netDeposits: number; +}): { + /** + * New NAV + */ + NAV: number; + /** + * How many shares exist including newly created or removed shares due + * to deposits/withdrawals + */ + newNumShares: number; +} { + const navWithoutCashDiff = (equity - netDeposits) / numShares; + const newNumShares = numShares + netDeposits / navWithoutCashDiff; + const newNAV = equity / newNumShares; + + return { NAV: newNAV, newNumShares }; +} diff --git a/src/util/calculatePNL.test.ts b/src/util/calculatePNL.test.ts new file mode 100644 index 0000000..ad15d64 --- /dev/null +++ b/src/util/calculatePNL.test.ts @@ -0,0 +1,202 @@ +import { ExtendedTradesDataType } from "@jaws/db/tradesMeta"; +import { ONE_DAY_IN_MS } from "@jaws/lib/helpers"; +import { RawOrder } from "@master-chief/alpaca/@types/entities"; +import { calculatePNL } from "./calculatePNL"; + +describe("calculatePNL", () => { + it("calculates PNL for empty list", () => { + const todayDate = new Date(2023, 1, 20, 17, 0, 0); + const startDate = new Date(Number(todayDate) - ONE_DAY_IN_MS * 21); + const endDate = todayDate; + + const pnl = calculatePNL({ + orders: [], + dateStart: startDate, + dateEnd: endDate, + trades: [], + }); + expect(pnl).toStrictEqual({ buyValue: 0, profit: 0, profitPercentage: 0 }); + }); + + it.each(getTestData())( + "calculates PNL correctly (%#)", + (orders, trades, result) => { + const todayDate = new Date(2023, 1, 21, 17, 0, 0); + const startDate = new Date(Number(todayDate) - ONE_DAY_IN_MS * 21); + const endDate = todayDate; + + const pnl = calculatePNL({ + orders: orders as RawOrder[], + dateStart: startDate, + dateEnd: endDate, + trades: trades as ExtendedTradesDataType[], + }); + + const { buyValue, profit, profitPercentage } = result; + + expect(pnl).toStrictEqual(expect.objectContaining({ buyValue, profit })); + expect(pnl.profitPercentage).toBeCloseTo(profitPercentage, 4); + }, + ); +}); + +function getTestData(): [ + Partial[], + Partial[], + { profit: number; profitPercentage: number; buyValue: number }, +][] { + return [ + [[], [], { profit: 0, profitPercentage: 0, buyValue: 0 }], + [ + [ + { + symbol: "AAPL", + filled_qty: "2", + side: "buy", + filled_avg_price: "100", + status: "filled", + filled_at: new Date(2023, 1, 3, 17, 0, 0).toISOString(), + id: "ID1", + }, + { + symbol: "AAPL", + filled_qty: "2", + side: "sell", + filled_avg_price: "110", + status: "filled", + filled_at: new Date(2023, 1, 3, 17, 0, 0).toISOString(), + id: "ID2", + }, + ], + [ + { + alpacaOrderId: "ID1", + alpacaStopLossOrderId: "ID2", + // alpacaTakeProfitOrderId: "b2e74a80-05ba-47d2-97ae-ab415921aace", + ticker: "AAPL", + }, + ], + { profit: 20, profitPercentage: 0.1, buyValue: 200 }, + ], + + [ + [ + { + symbol: "AAPL", + filled_qty: "2", + side: "buy", + filled_avg_price: "100", + status: "filled", + filled_at: new Date(2023, 1, 3, 17, 0, 0).toISOString(), + id: "ID1", + }, + { + symbol: "AAPL", + filled_qty: "1", + side: "buy", + filled_avg_price: "100", + status: "filled", + filled_at: new Date(2023, 1, 4, 17, 0, 0).toISOString(), + id: "ID2", + }, + { + symbol: "AAPL", + filled_qty: "2", + side: "sell", + filled_avg_price: "110", + status: "filled", + filled_at: new Date(2023, 1, 4, 17, 0, 0).toISOString(), + id: "ID3", + }, + { + symbol: "AAPL", + filled_qty: "1", + side: "sell", + filled_avg_price: "110", + status: "filled", + filled_at: new Date(2023, 1, 8, 17, 0, 0).toISOString(), + id: "ID4", + }, + ], + [ + { + alpacaOrderId: "ID1", + alpacaStopLossOrderId: "ID3", + // alpacaTakeProfitOrderId: "b2e74a80-05ba-47d2-97ae-ab415921aace", + ticker: "AAPL", + }, + { + alpacaOrderId: "ID2", + alpacaStopLossOrderId: "ID4", + // alpacaTakeProfitOrderId: "b2e74a80-05ba-47d2-97ae-ab415921aace", + ticker: "AAPL", + }, + ], + { profit: 30, profitPercentage: 0.1, buyValue: 300 }, + ], + [ + [ + { + symbol: "AAPL", + filled_qty: "2", + side: "buy", + filled_avg_price: "100", + status: "filled", + filled_at: new Date(2023, 1, 3, 17, 0, 0).toISOString(), + id: "ID1", + }, + { + symbol: "AAPL", + filled_qty: "1", + side: "buy", + filled_avg_price: "100", + status: "filled", + filled_at: new Date(2023, 1, 4, 17, 0, 0).toISOString(), + id: "ID2", + }, + { + symbol: "AAPL", + filled_qty: "1", + side: "sell", + filled_avg_price: "110", + status: "filled", + filled_at: new Date(2023, 1, 4, 17, 0, 0).toISOString(), + id: "ID3", + }, + { + symbol: "AAPL", + filled_qty: "1", + side: "sell", + filled_avg_price: "105", + status: "filled", + filled_at: new Date(2023, 1, 8, 17, 0, 0).toISOString(), + id: "ID4", + }, + { + symbol: "AAPL", + filled_qty: "1", + side: "sell", + filled_avg_price: "105", + status: "filled", + filled_at: new Date(2023, 1, 8, 17, 0, 0).toISOString(), + id: "ID5", + }, + ], + [ + { + alpacaOrderId: "ID2", + alpacaStopLossOrderId: "ID4", + // alpacaTakeProfitOrderId: "b2e74a80-05ba-47d2-97ae-ab415921aace", + ticker: "AAPL", + }, + { + alpacaOrderId: "ID1", + alpacaTakeProfitOrderId: "ID3", + alpacaStopLossOrderId: "ID5", + ticker: "AAPL", + }, + ], + { profit: 20, profitPercentage: 0.0667, buyValue: 300 }, + ], + ]; +} diff --git a/src/util/calculatePNL.ts b/src/util/calculatePNL.ts new file mode 100644 index 0000000..bf45d21 --- /dev/null +++ b/src/util/calculatePNL.ts @@ -0,0 +1,103 @@ +import { ExtendedTradesDataType } from "@jaws/db/tradesMeta"; +import { RawOrder } from "@master-chief/alpaca/@types/entities"; + +export const calculatePNL = ({ + orders, + dateStart, + dateEnd, + trades, +}: { + orders: RawOrder[]; + dateStart: Date; + dateEnd: Date; + trades: ExtendedTradesDataType[]; +}): { + buyValue: number; + profit: number; + profitPercentage: number; +} => { + const ordersById = groupBy(orders, "id"); + + const tradesPNL = getClosedPositionTrades(trades).map((trade) => { + tradeHasAlpacaIds(trade); + + const buyOrder = parseAlpacaOrder(ordersById[trade.alpacaOrderId][0]); + + const takeProfitOrder = trade.alpacaTakeProfitOrderId + ? parseAlpacaOrder(ordersById[trade.alpacaTakeProfitOrderId][0]) + : { filled_qty: 0, filled_avg_price: 0 }; + const stopLossOrder = trade.alpacaStopLossOrderId + ? parseAlpacaOrder(ordersById[trade.alpacaStopLossOrderId][0]) + : { filled_qty: 0, filled_avg_price: 0 }; + + const buyValue = buyOrder.filled_avg_price * buyOrder.filled_qty; + const profit = + takeProfitOrder.filled_avg_price * takeProfitOrder.filled_qty + + stopLossOrder.filled_avg_price * stopLossOrder.filled_qty - + buyValue; + + return { + profit, + buyValue, + profitPercentage: profit / buyValue, + }; + }); + + const summedTradesPNL = tradesPNL.reduce( + (acc, { profit, buyValue }) => { + acc.profit += profit; + acc.buyValue += buyValue; + acc.profitPercentage = acc.profit / acc.buyValue; + return acc; + }, + { + profit: 0, + buyValue: 0, + profitPercentage: 0, + }, + ); + + return summedTradesPNL; +}; + +function groupBy, K extends keyof T>( + list: T[], + key: K, +): { [k: string]: T[] } { + return list.reduce( + (r, v, i, a, k = v[key]) => ((r[k] || (r[k] = [])).push(v), r), + {} as { [k: string]: T[] }, + ); +} + +function getClosedPositionTrades(trades: ExtendedTradesDataType[]) { + return trades.filter( + (trade) => + trade.alpacaOrderId && + (trade.alpacaTakeProfitOrderId || trade.alpacaStopLossOrderId), + ); +} + +function tradeHasAlpacaIds( + trade: ExtendedTradesDataType, +): asserts trade is RequireSome< + ExtendedTradesDataType, + "alpacaOrderId" | "alpacaStopLossOrderId" | "alpacaTakeProfitOrderId" +> { + if ( + !( + trade.alpacaOrderId && + (trade.alpacaTakeProfitOrderId || trade.alpacaStopLossOrderId) + ) + ) { + throw new TypeError("Trade missing data: " + JSON.stringify(trade)); + } +} + +function parseAlpacaOrder(order: RawOrder) { + return { + ...order, + filled_avg_price: parseFloat(order.filled_avg_price), + filled_qty: parseInt(order.filled_qty), + }; +} diff --git a/src/util/handleResult.ts b/src/util/handleResult.ts index 3d4c721..ba4dd18 100644 --- a/src/util/handleResult.ts +++ b/src/util/handleResult.ts @@ -10,14 +10,7 @@ export const convertResult = async (result: Response): Promise => { }; export const handleResult = async (result: Response) => { - try { - const data = await convertResult(result); - const reason = { error: data }; - return result.ok - ? await Promise.resolve(data) - : await Promise.reject(reason); - } catch (error) { - const reason = { error }; - return Promise.reject(reason); - } + const data = await convertResult(result); + + return result.ok ? Promise.resolve(data) : Promise.reject(data); };