feat: added purchase stock component

This commit is contained in:
bracesproul
2025-03-06 11:10:15 -08:00
parent 06b865c1c2
commit f7c750eceb
6 changed files with 221 additions and 46 deletions

View File

@@ -6,7 +6,7 @@ import { z } from "zod";
import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { findToolCall } from "../../find-tool-call"; import { findToolCall } from "../../find-tool-call";
import { format, subDays } from "date-fns"; import { format, subDays } from "date-fns";
import { Price } from "../../types"; import { Price, Snapshot } from "../../types";
async function getPricesForTicker(ticker: string): Promise<{ async function getPricesForTicker(ticker: string): Promise<{
oneDayPrices: Price[]; oneDayPrices: Price[];
@@ -48,21 +48,44 @@ async function getPricesForTicker(ticker: string): Promise<{
fetch(`${url}?${queryParamsOneDay.toString()}`, options), fetch(`${url}?${queryParamsOneDay.toString()}`, options),
fetch(`${url}?${queryParamsThirtyDays.toString()}`, options), fetch(`${url}?${queryParamsThirtyDays.toString()}`, options),
]); ]);
if (!resOneDay.ok || !resThirtyDays.ok) { if (!resOneDay.ok || !resThirtyDays.ok) {
throw new Error("Failed to fetch prices"); throw new Error("Failed to fetch prices");
} }
const { prices: pricesOneDay } = await resOneDay.json(); const { prices: pricesOneDay } = await resOneDay.json();
const { prices: pricesThirtyDays } = await resThirtyDays.json(); const { prices: pricesThirtyDays } = await resThirtyDays.json();
console.log("pricesOneDay", pricesOneDay.length);
console.log("pricesThirtyDays", pricesThirtyDays.length);
return { return {
oneDayPrices: pricesOneDay, oneDayPrices: pricesOneDay,
thirtyDayPrices: pricesThirtyDays, thirtyDayPrices: pricesThirtyDays,
}; };
} }
async function getPriceSnapshotForTicker(ticker: string): Promise<Snapshot> {
if (!process.env.FINANCIAL_DATASETS_API_KEY) {
throw new Error("Financial datasets API key not set");
}
const options = {
method: "GET",
headers: { "X-API-KEY": process.env.FINANCIAL_DATASETS_API_KEY },
};
const url = "https://api.financialdatasets.ai/prices/snapshot";
const queryParams = new URLSearchParams({
ticker,
});
const response = await fetch(`${url}?${queryParams.toString()}`, options);
if (!response.ok) {
throw new Error("Failed to fetch price snapshot");
}
const { snapshot } = await response.json();
return snapshot;
}
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 }); const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const getStockPriceSchema = z.object({ const getStockPriceSchema = z.object({
@@ -71,6 +94,10 @@ const getStockPriceSchema = z.object({
const getPortfolioSchema = z.object({ const getPortfolioSchema = z.object({
get_portfolio: z.boolean().describe("Should be true."), get_portfolio: z.boolean().describe("Should be true."),
}); });
const buyStockSchema = z.object({
ticker: z.string().describe("The ticker symbol of the company"),
quantity: z.number().describe("The quantity of the stock to buy"),
});
const STOCKBROKER_TOOLS = [ const STOCKBROKER_TOOLS = [
{ {
@@ -84,6 +111,11 @@ const STOCKBROKER_TOOLS = [
"A tool to get the user's portfolio details. Only call this tool if the user requests their portfolio details.", "A tool to get the user's portfolio details. Only call this tool if the user requests their portfolio details.",
schema: getPortfolioSchema, schema: getPortfolioSchema,
}, },
{
name: "buy-stock",
description: "A tool to buy a stock",
schema: buyStockSchema,
},
]; ];
export async function callTools( export async function callTools(
@@ -107,6 +139,9 @@ export async function callTools(
const portfolioToolCall = message.tool_calls?.find( const portfolioToolCall = message.tool_calls?.find(
findToolCall("portfolio")<typeof getPortfolioSchema>, findToolCall("portfolio")<typeof getPortfolioSchema>,
); );
const buyStockToolCall = message.tool_calls?.find(
findToolCall("buy-stock")<typeof buyStockSchema>,
);
if (stockbrokerToolCall) { if (stockbrokerToolCall) {
const prices = await getPricesForTicker(stockbrokerToolCall.args.ticker); const prices = await getPricesForTicker(stockbrokerToolCall.args.ticker);
@@ -115,10 +150,20 @@ export async function callTools(
...prices, ...prices,
}); });
} }
if (portfolioToolCall) { if (portfolioToolCall) {
ui.write("portfolio", {}); ui.write("portfolio", {});
} }
if (buyStockToolCall) {
const snapshot = await getPriceSnapshotForTicker(
buyStockToolCall.args.ticker,
);
ui.write("buy-stock", {
toolCallId:
message.tool_calls?.find((tc) => tc.name === "buy-stock")?.id ?? "",
snapshot,
quantity: buyStockToolCall.args.quantity,
});
}
return { return {
messages: [message], messages: [message],

View File

@@ -35,3 +35,12 @@ export type Price = {
volume: number; volume: number;
time: string; time: string;
}; };
export type Snapshot = {
price: number;
ticker: string;
day_change: number;
day_change_percent: number;
market_cap: number;
time: string;
};

View File

@@ -4,6 +4,7 @@ import AccommodationsList from "./trip-planner/accommodations-list";
import BookAccommodation from "./trip-planner/book-accommodation"; import BookAccommodation from "./trip-planner/book-accommodation";
import RestaurantsList from "./trip-planner/restaurants-list"; import RestaurantsList from "./trip-planner/restaurants-list";
import BookRestaurant from "./trip-planner/book-restaurant"; import BookRestaurant from "./trip-planner/book-restaurant";
import BuyStock from "./stockbroker/buy-stock";
const ComponentMap = { const ComponentMap = {
"stock-price": StockPrice, "stock-price": StockPrice,
@@ -12,5 +13,6 @@ const ComponentMap = {
"book-accommodation": BookAccommodation, "book-accommodation": BookAccommodation,
"restaurants-list": RestaurantsList, "restaurants-list": RestaurantsList,
"book-restaurant": BookRestaurant, "book-restaurant": BookRestaurant,
"buy-stock": BuyStock,
} as const; } as const;
export default ComponentMap; export default ComponentMap;

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,139 @@
import "./index.css";
import { v4 as uuidv4 } from "uuid";
import { Snapshot } from "../../../types";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { UIMessage, useStreamContext } from "@langchain/langgraph-sdk/react-ui";
import { Message } from "@langchain/langgraph-sdk";
import { getToolResponse } from "agent/uis/utils/get-tool-response";
import { DO_NOT_RENDER_ID_PREFIX } from "@/lib/ensure-tool-responses";
function Purchased({
ticker,
quantity,
price,
}: {
ticker: string;
quantity: number;
price: number;
}) {
return (
<div className="w-full md:w-lg rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">
<h1 className="text-xl font-medium mb-2">Purchase Executed - {ticker}</h1>
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div className="flex flex-col gap-2">
<p>Number of Shares</p>
<p>Market Price</p>
<p>Total Cost</p>
</div>
<div className="flex flex-col gap-2 items-end justify-end">
<p>{quantity}</p>
<p>${price}</p>
<p>${(quantity * price).toFixed(2)}</p>
</div>
</div>
</div>
);
}
export default function BuyStock(props: {
toolCallId: string;
snapshot: Snapshot;
quantity: number;
}) {
const { snapshot, toolCallId } = props;
const [quantity, setQuantity] = useState(props.quantity);
const [finalPurchase, setFinalPurchase] = useState<{
ticker: string;
quantity: number;
price: number;
}>();
const thread = useStreamContext<
{ messages: Message[]; ui: UIMessage[] },
{ MetaType: { ui: UIMessage | undefined } }
>();
useEffect(() => {
if (typeof window === "undefined" || finalPurchase) return;
const toolResponse = getToolResponse(toolCallId, thread);
if (toolResponse) {
try {
const parsedContent: {
purchaseDetails: {
ticker: string;
quantity: number;
price: number;
};
} = JSON.parse(toolResponse.content as string);
setFinalPurchase(parsedContent.purchaseDetails);
} catch {
console.error("Failed to parse tool response content.");
}
}
}, []);
function handleBuyStock() {
const orderDetails = {
message: "Successfully purchased stock",
purchaseDetails: {
ticker: snapshot.ticker,
quantity: quantity,
price: snapshot.price,
},
};
thread.submit({
messages: [
{
type: "tool",
tool_call_id: toolCallId,
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
name: "buy-stock",
content: JSON.stringify(orderDetails),
},
{
type: "human",
content: `Purchased ${quantity} shares of ${snapshot.ticker} at ${snapshot.price} per share`,
},
],
});
setFinalPurchase(orderDetails.purchaseDetails);
}
if (finalPurchase) {
return <Purchased {...finalPurchase} />;
}
return (
<div className="w-full md:w-lg rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">
<h1 className="text-xl font-medium mb-2">Buy {snapshot.ticker}</h1>
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div className="flex flex-col gap-2">
<p>Number of Shares</p>
<p>Market Price</p>
<p>Total Cost</p>
</div>
<div className="flex flex-col gap-2 items-end justify-end">
<Input
type="number"
className="max-w-[100px] border-0 border-b focus:border-b-2 rounded-none shadow-none focus:ring-0"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min={1}
/>
<p>${snapshot.price}</p>
<p>${(quantity * snapshot.price).toFixed(2)}</p>
</div>
</div>
<Button
className="w-full bg-green-600 hover:bg-green-700 transition-colors ease-in-out duration-200 cursor-pointer text-white"
onClick={handleBuyStock}
>
Buy
</Button>
</div>
);
}

View File

@@ -1,9 +1,4 @@
import "./index.css"; import "./index.css";
import {
useStreamContext,
type UIMessage,
} from "@langchain/langgraph-sdk/react-ui";
import type { Message } from "@langchain/langgraph-sdk";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { import {
ChartConfig, ChartConfig,
@@ -65,42 +60,26 @@ function DisplayRangeSelector({
); );
} }
function getPropsForDisplayRange(displayRange: DisplayRange, prices: Price[]) { function getPropsForDisplayRange(
// Start by filtering prices by display range. use the `time` field on `price` which is a string date. compare it to the current date displayRange: DisplayRange,
const actualPrices: Price[] = []; oneDayPrices: Price[],
thirtyDayPrices: Price[],
) {
const now = new Date(); const now = new Date();
const oneDay = 24 * 60 * 60 * 1000; const fiveDays = 5 * 24 * 60 * 60 * 1000; // 5 days in milliseconds
const fiveDays = 5 * oneDay;
const oneMonth = 30 * oneDay;
switch (displayRange) { switch (displayRange) {
case "1d": case "1d":
console.log("Calculating for 1d", prices.length); return oneDayPrices;
console.log(prices[prices.length - 1]);
actualPrices.push(
...prices.filter(
(p) => new Date(p.time).getTime() >= now.getTime() - oneDay,
),
);
break;
case "5d": case "5d":
console.log("Calculating for 5d", prices.length); return thirtyDayPrices.filter(
actualPrices.push( (p) => new Date(p.time).getTime() >= now.getTime() - fiveDays,
...prices.filter(
(p) => new Date(p.time).getTime() >= now.getTime() - fiveDays,
),
); );
break;
case "1m": case "1m":
console.log("Calculating for 1m", prices.length); return thirtyDayPrices;
actualPrices.push( default:
...prices.filter( return [];
(p) => new Date(p.time).getTime() >= now.getTime() - oneMonth,
),
);
break;
} }
return actualPrices;
} }
// TODO: UPDATE TO SUPPORT ONE DAY AND THIRTY DAY PRICES AS DIFFERENT PROPS // TODO: UPDATE TO SUPPORT ONE DAY AND THIRTY DAY PRICES AS DIFFERENT PROPS
export default function StockPrice(props: { export default function StockPrice(props: {
@@ -109,12 +88,8 @@ export default function StockPrice(props: {
thirtyDayPrices: Price[]; thirtyDayPrices: Price[];
}) { }) {
const { ticker } = props; const { ticker } = props;
console.log(props.prices[0], props.prices[props.prices.length - 1]); const { oneDayPrices, thirtyDayPrices } = props;
const [displayRange, setDisplayRange] = useState<DisplayRange>("1d"); const [displayRange, setDisplayRange] = useState<DisplayRange>("1d");
const thread = useStreamContext<
{ messages: Message[]; ui: UIMessage[] },
{ MetaType: { ui: UIMessage | undefined } }
>();
const { const {
currentPrice, currentPrice,
@@ -126,8 +101,12 @@ export default function StockPrice(props: {
chartData, chartData,
change, change,
} = useMemo(() => { } = useMemo(() => {
const prices = getPropsForDisplayRange(displayRange, props.prices); const prices = getPropsForDisplayRange(
console.log("prices", prices.length); displayRange,
oneDayPrices,
thirtyDayPrices,
);
const firstPrice = prices[0]; const firstPrice = prices[0];
const lastPrice = prices[prices.length - 1]; const lastPrice = prices[prices.length - 1];
@@ -158,7 +137,7 @@ export default function StockPrice(props: {
chartData, chartData,
change, change,
}; };
}, [props.prices, displayRange]); }, [oneDayPrices, thirtyDayPrices, displayRange]);
return ( return (
<div className="w-full max-w-4xl rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3"> <div className="w-full max-w-4xl rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">