├── CODEOWNERS ├── .prettierignore ├── .yarnrc.yml ├── .env.example ├── .dockerignore ├── public └── favicon.ico ├── prettier.config.mjs ├── app ├── components │ ├── ItemSelect.module.css │ ├── RecentSales.module.css │ ├── Volume.tsx │ ├── Spend.tsx │ ├── RecentSales.tsx │ ├── ItemSelect.tsx │ └── Chart.tsx ├── routes.ts ├── routes │ ├── home.module.css │ ├── api.$itemid.tsx │ └── home.tsx ├── utils.ts ├── types.ts ├── root.tsx └── db.server.ts ├── .editorconfig ├── .gitattributes ├── Dockerfile ├── react-router.config.ts ├── vite.config.ts ├── .gitignore ├── kysely.config.ts ├── migrations ├── 1764425535946_exclude_own_shop.ts └── 1764276811815_init.ts ├── .github └── workflows │ └── test.yml ├── tsconfig.json ├── scripts ├── value.ts ├── econ.test.ts ├── econ.ts ├── etl.ts └── value.test.ts ├── README.md └── package.json /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gausie 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | generated/ 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | USERNAME= 2 | PASSWORD= 3 | DATABASE_URL= 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .react-router 2 | build 3 | node_modules 4 | README.md -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loathers/pricegun/main/public/favicon.ico -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | const config = {}; 3 | 4 | export default config; 5 | -------------------------------------------------------------------------------- /app/components/ItemSelect.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: row; 4 | gap: 0.5rem; 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /app/components/RecentSales.module.css: -------------------------------------------------------------------------------- 1 | .list { 2 | column-gap: 0; 3 | column-count: 3; 4 | } 5 | 6 | @media (max-width: 800px) { 7 | .list { 8 | column-count: 1; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:23-alpine 2 | COPY . /app/ 3 | WORKDIR /app 4 | RUN corepack enable 5 | RUN yarn install 6 | RUN yarn build 7 | RUN yarn kysely migrate:latest 8 | EXPOSE 3000 9 | CMD ["yarn", "start"] 10 | -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import { type RouteConfig, index, route } from "@react-router/dev/routes"; 2 | 3 | export default [ 4 | index("routes/home.tsx"), 5 | route("api/:itemid", "routes/api.$itemid.tsx"), 6 | ] satisfies RouteConfig; 7 | -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | // Config options... 5 | // Server-side render by default, to enable SPA mode set this to `false` 6 | ssr: true, 7 | } satisfies Config; 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { reactRouter } from "@react-router/dev/vite"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | export default defineConfig({ 6 | plugins: [reactRouter(), tsconfigPaths()], 7 | }); 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | /node_modules/ 4 | .vscode/ 5 | 6 | # React Router 7 | /.react-router/ 8 | /build/ 9 | 10 | # Yarn with no Zero-Installs 11 | .pnp.* 12 | .yarn/* 13 | !.yarn/patches 14 | !.yarn/plugins 15 | !.yarn/releases 16 | !.yarn/sdks 17 | !.yarn/versions 18 | -------------------------------------------------------------------------------- /kysely.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "kysely-ctl"; 2 | import { Pool } from "pg"; 3 | import dotenv from "dotenv"; 4 | 5 | dotenv.config(); 6 | 7 | export default defineConfig({ 8 | dialect: "pg", 9 | dialectConfig: { 10 | pool: new Pool({ connectionString: process.env.DATABASE_URL }), 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /app/routes/home.module.css: -------------------------------------------------------------------------------- 1 | .homeContainer { 2 | display: flex; 3 | flex-direction: row; 4 | gap: 2em; 5 | } 6 | 7 | .chart { 8 | flex-basis: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | gap: 1em; 12 | } 13 | 14 | @media only screen and (max-width: 1000px) { 15 | .homeContainer { 16 | flex-direction: column; 17 | } 18 | 19 | .chart { 20 | flex-basis: 400px; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /migrations/1764425535946_exclude_own_shop.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from "kysely"; 2 | import { recalculateValues } from "../scripts/etl.js"; 3 | 4 | export async function up(db: Kysely): Promise { 5 | const items = await db 6 | .deleteFrom("Sale") 7 | .whereRef("buyerId", "=", "sellerId") 8 | .returning("itemId") 9 | .execute(); 10 | 11 | await recalculateValues([...new Set(items.map((i) => i.itemId))]); 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | name: Run unit tests 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Enable Corepack 13 | run: corepack enable 14 | 15 | - uses: actions/setup-node@v6 16 | with: 17 | node-version: "latest" 18 | cache: "yarn" 19 | 20 | - run: yarn 21 | - run: yarn test 22 | -------------------------------------------------------------------------------- /app/utils.ts: -------------------------------------------------------------------------------- 1 | export const numberFormatter = new Intl.NumberFormat(undefined); 2 | export const shortNumberFormatter = new Intl.NumberFormat(undefined, { 3 | notation: "compact", 4 | compactDisplay: "short", 5 | }); 6 | export const shortDateFormatter = new Intl.DateTimeFormat(undefined, { 7 | month: "short", 8 | day: "numeric", 9 | }); 10 | 11 | export const dateFormatter = new Intl.DateTimeFormat(undefined, { 12 | dateStyle: "short", 13 | timeStyle: "short", 14 | }); 15 | -------------------------------------------------------------------------------- /app/components/Volume.tsx: -------------------------------------------------------------------------------- 1 | import { numberFormatter } from "~/utils"; 2 | 3 | type Props = { 4 | data: { 5 | itemId: number; 6 | quantity: number; 7 | name: string; 8 | }[]; 9 | }; 10 | 11 | export function Volume({ data }: Props) { 12 | return ( 13 |
14 |

Top Volume (last 24h)

15 |
    16 | {data.map((item) => ( 17 |
  1. 18 | {item.name}: {numberFormatter.format(item.quantity)} 19 |
  2. 20 | ))} 21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/components/Spend.tsx: -------------------------------------------------------------------------------- 1 | import { numberFormatter } from "~/utils"; 2 | 3 | type Props = { 4 | data: { 5 | itemId: number; 6 | quantity: number; 7 | spend: number; 8 | name: string; 9 | }[]; 10 | }; 11 | 12 | export function Spend({ data }: Props) { 13 | return ( 14 |
15 |

Top Spend (last 24h)

16 |
    17 | {data.map((item) => ( 18 |
  1. 19 | {item.name} x {numberFormatter.format(item.quantity)}:{" "} 20 | {numberFormatter.format(item.spend)} meat 21 |
  2. 22 | ))} 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*", 4 | "**/.server/**/*", 5 | "**/.client/**/*", 6 | ".react-router/types/**/*" 7 | ], 8 | "compilerOptions": { 9 | "lib": ["DOM", "DOM.Iterable", "ES2024"], 10 | "types": ["node", "vite/client"], 11 | "target": "ES2024", 12 | "module": "ES2022", 13 | "moduleResolution": "bundler", 14 | "jsx": "react-jsx", 15 | "rootDirs": [".", "./.react-router/types"], 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | "plugins": [{ "name": "typescript-plugin-css-modules" }], 21 | "esModuleInterop": true, 22 | "verbatimModuleSyntax": true, 23 | "noEmit": true, 24 | "resolveJsonModule": true, 25 | "skipLibCheck": true, 26 | "strict": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /scripts/value.ts: -------------------------------------------------------------------------------- 1 | import { differenceInSeconds, subDays } from "date-fns"; 2 | import type { Sale } from "~/types"; 3 | 4 | const VOLUME_EXPONENT = 0.5; 5 | const HL = Math.LN2 / 259_200; // Three days 6 | 7 | export function deriveValue(sales: Sale[]) { 8 | if (sales.length === 0) return 0; 9 | 10 | const epoch = new Date(); 11 | 12 | const [numerator, denominator] = sales.reduce( 13 | ([n, d], s) => { 14 | const age = differenceInSeconds(epoch, s.date); 15 | const timeValue = Math.exp(-HL * age); 16 | const volumeValue = s.quantity ** VOLUME_EXPONENT; 17 | return [ 18 | s.unitPrice * timeValue * volumeValue + n, 19 | d + timeValue * volumeValue, 20 | ]; 21 | }, 22 | [0, 0], 23 | ); 24 | 25 | return Number((numerator / denominator).toFixed(2)); 26 | } 27 | -------------------------------------------------------------------------------- /app/components/RecentSales.tsx: -------------------------------------------------------------------------------- 1 | import { dateFormatter, numberFormatter } from "~/utils"; 2 | import styles from "./RecentSales.module.css"; 3 | import type { Item } from "./ItemSelect"; 4 | 5 | type Props = { 6 | item: Item | null; 7 | sales: { date: Date; unitPrice: number; quantity: number }[]; 8 | }; 9 | 10 | export function RecentSales({ item, sales }: Props) { 11 | if (!item) return null; 12 | if (sales.length === 0) return null; 13 | return ( 14 |
15 |

Recent Sales

16 |
    17 | {sales.map((sale) => ( 18 |
  1. 21 | {dateFormatter.format(sale.date)} - {sale.quantity} @{" "} 22 | {numberFormatter.format(sale.unitPrice)} 23 |
  2. 24 | ))} 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pricegun 2 | 3 | ## Method 4 | 5 | The algorithm is constantly being improved and updated, but is currently based on calculating a modified Time Weighted Average Price as follows 6 | 7 | ```math 8 | P_{\mathrm{TWAP}} = \frac{\sum_{j}{P_j \cdot w_j \cdot V_j^E}}{\sum_{j}{w_j \cdot V_j^E}} 9 | ``` 10 | 11 | ```math 12 | w_j = e^{-\lambda T_j} 13 | ``` 14 | 15 | ```math 16 | \lambda = \frac{ln(2)}{H} 17 | ``` 18 | 19 | where 20 | 21 | - $P_{\mathrm{TWAP}}$ is the Time Weighted Average Price 22 | - $P_j$ is the price of the item at a given transaction 23 | - $w_j$ is the time weighting coefficient 24 | - $T_j$ is the time since the transaction epoch (now) in seconds 25 | - $H$ is the halflife for transaction weighting (three days) in seconds 26 | - $V_j$ is the volume of a given transaction 27 | - $E$ is a factor for dampening the impact of sudden high volume transactions 28 | - $j$ is the individual transaction 29 | 30 | ## Development 31 | 32 | To install dependencies: 33 | 34 | ```bash 35 | yarn 36 | ``` 37 | 38 | To run: 39 | 40 | ```bash 41 | yarn start 42 | ``` 43 | -------------------------------------------------------------------------------- /app/routes/api.$itemid.tsx: -------------------------------------------------------------------------------- 1 | import { data } from "react-router"; 2 | import type { Route } from "./+types/api.$itemid.js"; 3 | import { getItemWithSales } from "~/db.server.js"; 4 | 5 | export async function loader({ params }: Route.LoaderArgs) { 6 | const itemIds = params["itemid"]!.split(",") 7 | .map(Number) 8 | .filter(Number.isInteger); 9 | 10 | const itemData = ( 11 | await Promise.all(itemIds.map((itemId) => getItemWithSales(itemId))) 12 | ).filter((i) => i !== null); 13 | 14 | if (itemData.length === 0) { 15 | return data( 16 | { error: "Not enough mall data to service request" }, 17 | { 18 | status: 404, 19 | headers: { 20 | "Access-Control-Allow-Origin": "*", 21 | }, 22 | }, 23 | ); 24 | } 25 | 26 | // Check itemIds here. We may have filtered out ones for which we don't have data 27 | // but the user will be expecting an array. 28 | if (itemIds.length === 1) { 29 | return data(itemData[0], { 30 | headers: { 31 | "Access-Control-Allow-Origin": "*", 32 | }, 33 | }); 34 | } 35 | 36 | return data(itemData, { 37 | headers: { 38 | "Access-Control-Allow-Origin": "*", 39 | }, 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /app/types.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnType, Insertable, Selectable, Updateable } from "kysely"; 2 | export type Generated = 3 | T extends ColumnType 4 | ? ColumnType 5 | : ColumnType; 6 | export type Timestamp = ColumnType; 7 | 8 | export const SaleSource = { 9 | mall: "mall", 10 | flea: "flea", 11 | } as const; 12 | export type SaleSource = (typeof SaleSource)[keyof typeof SaleSource]; 13 | export type ItemTable = { 14 | value: number; 15 | volume: number; 16 | date: Timestamp; 17 | itemId: number; 18 | name: string | null; 19 | image: Generated; 20 | }; 21 | export type Item = Selectable; 22 | export type NewItem = Insertable; 23 | export type ItemUpdate = Updateable; 24 | 25 | export type SaleTable = { 26 | source: SaleSource; 27 | buyerId: number; 28 | sellerId: number; 29 | itemId: number; 30 | quantity: number; 31 | unitPrice: number; 32 | date: Timestamp; 33 | }; 34 | export type Sale = Selectable; 35 | export type NewSale = Insertable; 36 | export type SaleUpdate = Updateable; 37 | 38 | export type Database = { 39 | Item: ItemTable; 40 | Sale: SaleTable; 41 | }; 42 | -------------------------------------------------------------------------------- /scripts/econ.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { parseLine } from "./econ"; 3 | 4 | test("Parse simple mall line", () => { 5 | const line = "m,1197090,1345884,641,1,100,2024-05-23 12:08:40"; 6 | const actual = parseLine(line); 7 | 8 | expect(actual).toEqual({ 9 | buyer: 1197090, 10 | seller: 1345884, 11 | item: 641, 12 | quantity: 1, 13 | unitPrice: 100, 14 | source: "mall", 15 | date: new Date("2024-05-23 12:08:40"), 16 | }); 17 | }); 18 | 19 | test("Parse quantity mall line", () => { 20 | const line = "m,1197090,2270868,641,16,1600,2024-05-30 17:45:29"; 21 | const actual = parseLine(line); 22 | 23 | expect(actual).toEqual({ 24 | buyer: 1197090, 25 | seller: 2270868, 26 | item: 641, 27 | quantity: 16, 28 | unitPrice: 100, 29 | source: "mall", 30 | date: new Date("2024-05-30 17:45:29"), 31 | }); 32 | }); 33 | 34 | test("Parse flea market line", () => { 35 | const line = "f,1197090,100105,319,1,720,2024-05-19 03:56:36"; 36 | const actual = parseLine(line); 37 | 38 | expect(actual).toEqual({ 39 | buyer: 1197090, 40 | seller: 100105, 41 | item: 319, 42 | quantity: 1, 43 | unitPrice: 720, 44 | source: "flea", 45 | date: new Date("2024-05-19 03:56:36"), 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /app/components/ItemSelect.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from "react"; 2 | 3 | import styles from "./ItemSelect.module.css"; 4 | 5 | export type Item = { itemId: number; name: string | null }; 6 | 7 | type Props = { 8 | items: Item[]; 9 | value: Item | null; 10 | onChange: (value: Item) => void; 11 | }; 12 | 13 | export function ItemSelect({ items, value, onChange }: Props) { 14 | const filteredItems = useMemo( 15 | () => items.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")), 16 | [items, value], 17 | ); 18 | 19 | const handleSelect = useCallback( 20 | (e: React.ChangeEvent) => { 21 | const itemId = Number(e.target.value); 22 | const item = items.find((i) => i.itemId === itemId); 23 | if (item && item.itemId !== value?.itemId) { 24 | onChange(item); 25 | } 26 | }, 27 | [items, onChange, value], 28 | ); 29 | 30 | return ( 31 |
32 | 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pricegun", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "react-router build", 7 | "dev": "react-router dev", 8 | "start": "react-router-serve ./build/server/index.js", 9 | "typecheck": "react-router typegen && tsc", 10 | "test": "vitest", 11 | "etl": "node --env-file-if-exists .env --import tsx ./scripts/etl.ts", 12 | "format": "prettier --write ." 13 | }, 14 | "engines": { 15 | "node": ">=23" 16 | }, 17 | "dependencies": { 18 | "@react-router/node": "^7.11.0", 19 | "@react-router/serve": "^7.11.0", 20 | "clsx": "^2.1.1", 21 | "data-of-loathing": "^2.6.2", 22 | "date-fns": "^4.1.0", 23 | "dotenv": "^17.2.3", 24 | "isbot": "^5.1.32", 25 | "kysely": "^0.28.9", 26 | "pg": "^8.16.3", 27 | "react": "^19.2.3", 28 | "react-dom": "^19.2.3", 29 | "react-is": "^19.2.3", 30 | "react-router": "^7.11.0", 31 | "recharts": "^3.6.0" 32 | }, 33 | "devDependencies": { 34 | "@react-router/dev": "^7.11.0", 35 | "@types/node": "^25.0.3", 36 | "@types/pg": "^8.16.0", 37 | "@types/react": "^19.2.7", 38 | "@types/react-dom": "^19.2.3", 39 | "@types/react-is": "^19.2.0", 40 | "kysely-ctl": "^0.19.0", 41 | "prettier": "^3.7.4", 42 | "tsx": "^4.21.0", 43 | "typescript": "^5.9.3", 44 | "typescript-plugin-css-modules": "^5.2.0", 45 | "vite": "^7.3.0", 46 | "vite-tsconfig-paths": "^6.0.3", 47 | "vitest": "^4.0.16" 48 | }, 49 | "packageManager": "yarn@4.11.0" 50 | } 51 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "react-router"; 9 | 10 | import type { Route } from "./+types/root"; 11 | 12 | export const links: Route.LinksFunction = () => [ 13 | { rel: "preconnect", href: "https://fonts.googleapis.com" }, 14 | { 15 | rel: "preconnect", 16 | href: "https://fonts.gstatic.com", 17 | crossOrigin: "anonymous", 18 | }, 19 | { 20 | rel: "stylesheet", 21 | href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", 22 | }, 23 | ]; 24 | 25 | export function Layout({ children }: { children: React.ReactNode }) { 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {children} 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | export default function App() { 44 | return ; 45 | } 46 | 47 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 48 | let message = "Oops!"; 49 | let details = "An unexpected error occurred."; 50 | let stack: string | undefined; 51 | 52 | if (isRouteErrorResponse(error)) { 53 | message = error.status === 404 ? "404" : "Error"; 54 | details = 55 | error.status === 404 56 | ? "The requested page could not be found." 57 | : error.statusText || details; 58 | } else if (import.meta.env.DEV && error && error instanceof Error) { 59 | details = error.message; 60 | stack = error.stack; 61 | } 62 | 63 | return ( 64 |
65 |

{message}

66 |

{details}

67 | {stack && ( 68 |
69 |           {stack}
70 |         
71 | )} 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /scripts/econ.ts: -------------------------------------------------------------------------------- 1 | import { format, subDays } from "date-fns"; 2 | 3 | const BASIC_TOKEN = Buffer.from( 4 | `${process.env["USERNAME"]}:${process.env["PASSWORD"]}`, 5 | ).toString("base64"); 6 | 7 | export type SaleResponse = { 8 | source: "mall" | "flea"; 9 | buyer: number; 10 | seller: number; 11 | item: number; 12 | quantity: number; 13 | unitPrice: number; 14 | date: Date; 15 | }; 16 | 17 | function getBounds( 18 | start: Date | undefined, 19 | end: Date | undefined, 20 | ): [start: Date, end: Date] { 21 | const e = end ?? new Date(); 22 | if (!start) { 23 | return [subDays(e, 16), e]; 24 | } 25 | 26 | return [start, e]; 27 | } 28 | 29 | export function parseLine(line: string): SaleResponse { 30 | const parts = line.split(","); 31 | const quantity = Number(parts[4]); 32 | return { 33 | source: parts[0] === "m" ? "mall" : "flea", 34 | buyer: Number(parts[1]), 35 | seller: Number(parts[2]), 36 | item: Number(parts[3]), 37 | quantity, 38 | unitPrice: Number(parts[5]) / quantity, 39 | date: new Date(parts[6]), 40 | }; 41 | } 42 | 43 | export async function query( 44 | items: number | number[] | null, 45 | start?: Date, 46 | end?: Date, 47 | ): Promise { 48 | const [startDate, endDate] = getBounds(start, end); 49 | 50 | const body = new URLSearchParams({ 51 | startstamp: format(startDate, "yyyyMMddHHmmss"), 52 | endstamp: format(endDate, "yyyyMMddHHmmss"), 53 | items: Array.isArray(items) 54 | ? items.join(",") 55 | : items === null 56 | ? "" 57 | : items.toString(), 58 | source: "0", 59 | }); 60 | 61 | const response = await fetch( 62 | "https://dev.kingdomofloathing.com/econ/result.php", 63 | { 64 | method: "POST", 65 | body, 66 | headers: new Headers({ 67 | Authorization: `Basic ${BASIC_TOKEN}`, 68 | }), 69 | }, 70 | ); 71 | 72 | return (await response.text()) 73 | .trim() 74 | .split("\n") 75 | .slice(1, -1) // Remove
 and 
76 | .map(parseLine) 77 | .filter((s) => s.buyer !== s.seller); 78 | } 79 | -------------------------------------------------------------------------------- /migrations/1764276811815_init.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await sql` 5 | DO $$ 6 | BEGIN 7 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'SaleSource') THEN 8 | CREATE TYPE "SaleSource" AS ENUM ('mall', 'flea'); 9 | END IF; 10 | END 11 | $$; 12 | `.execute(db); 13 | 14 | db.schema 15 | .createTable("Item") 16 | .ifNotExists() 17 | .addColumn("itemId", "integer", (col) => col.primaryKey()) 18 | .addColumn("value", "numeric", (col) => col.notNull()) 19 | .addColumn("volume", "integer", (col) => col.notNull()) 20 | .addColumn("date", "timestamptz", (col) => col.notNull()) 21 | .addColumn("name", "text") 22 | .addColumn("image", "text", (col) => col.notNull().defaultTo("'nopic.gif'")) 23 | .execute(); 24 | 25 | db.schema 26 | .createTable("Sale") 27 | .ifNotExists() 28 | .addColumn("source", sql`"SaleSource"`, (col) => col.notNull()) 29 | .addColumn("buyerId", "integer", (col) => col.notNull()) 30 | .addColumn("sellerId", "integer", (col) => col.notNull()) 31 | .addColumn("itemId", "integer", (col) => col.notNull()) 32 | .addColumn("quantity", "integer", (col) => col.notNull()) 33 | .addColumn("unitPrice", "numeric", (col) => col.notNull()) 34 | .addColumn("date", "timestamptz", (col) => col.notNull()) 35 | .addPrimaryKeyConstraint("Sale_pkey", [ 36 | "source", 37 | "buyerId", 38 | "sellerId", 39 | "itemId", 40 | "date", 41 | ]) 42 | .addForeignKeyConstraint("Sale_itemId_fkey", ["itemId"], "Item", ["itemId"]) 43 | .execute(); 44 | 45 | await db.schema.dropTable("_prisma_migrations").ifExists().execute(); 46 | } 47 | 48 | export async function down(db: Kysely): Promise { 49 | await db.schema.dropTable("Sale").ifExists().execute(); 50 | await db.schema.dropTable("Item").ifExists().execute(); 51 | 52 | await sql` 53 | DO $$ 54 | BEGIN 55 | IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'salesource') THEN 56 | DROP TYPE "SaleSource"; 57 | END IF; 58 | END 59 | $$; 60 | `.execute(db); 61 | } 62 | -------------------------------------------------------------------------------- /app/routes/home.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from "react-router"; 2 | 3 | import styles from "./home.module.css"; 4 | import { 5 | getAllItems, 6 | getSpendLeaderboard, 7 | getTotalSales, 8 | getVolumeLeaderboard, 9 | } from "~/db.server"; 10 | import type { Route } from "./+types/home"; 11 | import { Chart } from "~/components/Chart"; 12 | import { numberFormatter } from "~/utils"; 13 | import { Spend } from "~/components/Spend"; 14 | import { Volume } from "~/components/Volume"; 15 | import { useEffect, useState } from "react"; 16 | import { ItemSelect, type Item } from "~/components/ItemSelect"; 17 | 18 | export function meta({}: Route.MetaArgs) { 19 | return [ 20 | { title: "Pricegun 🏷️🔫" }, 21 | { 22 | name: "description", 23 | content: "Better pricing and mall tracking for the Kingdom of Loathing", 24 | }, 25 | ]; 26 | } 27 | 28 | export async function loader() { 29 | const since = new Date(Date.now() - 24 * 60 * 60 * 1000); 30 | 31 | const volume = await getVolumeLeaderboard(since); 32 | const spend = await getSpendLeaderboard(since); 33 | const total = await getTotalSales(); 34 | const items = await getAllItems(); 35 | 36 | return { volume, spend, total, items }; 37 | } 38 | 39 | export default function Home() { 40 | const { volume, spend, total, items } = useLoaderData(); 41 | const [selectedItem, setSelectedItem] = useState(null); 42 | 43 | useEffect(() => { 44 | setSelectedItem((s) => s ?? volume[0]); 45 | }, [volume]); 46 | 47 | return ( 48 |
49 |
50 |
51 |

Pricegun 🏷️ 🔫

52 |

Now tracking {numberFormatter.format(total)} transactions!

53 |
54 |
55 | 56 | 57 |
58 |
59 |
60 | 65 | 66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /app/db.server.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from "./types.js"; 2 | import { Pool } from "pg"; 3 | import { Kysely, PostgresDialect, sql } from "kysely"; 4 | 5 | const dialect = new PostgresDialect({ 6 | pool: new Pool({ 7 | connectionString: process.env.DATABASE_URL, 8 | }), 9 | }); 10 | 11 | export const db = new Kysely({ 12 | dialect, 13 | }); 14 | 15 | export async function getVolumeLeaderboard(since: Date) { 16 | const results = await db 17 | .selectFrom("Sale") 18 | .leftJoin("Item", "Sale.itemId", "Item.itemId") 19 | .select([ 20 | "Sale.itemId", 21 | "Item.name", 22 | db.fn.sum("Sale.quantity").as("quantity"), 23 | ]) 24 | .where("Sale.date", ">=", since) 25 | .groupBy(["Sale.itemId", "Item.name"]) 26 | .orderBy("quantity", "desc") 27 | .limit(10) 28 | .execute(); 29 | 30 | return results.map((r) => ({ 31 | ...r, 32 | name: r.name ?? `[${r.itemId}]`, 33 | quantity: r.quantity ?? 0, 34 | })); 35 | } 36 | 37 | export async function getSpendLeaderboard(since: Date) { 38 | const results = await db 39 | .selectFrom("Sale") 40 | .leftJoin("Item", "Sale.itemId", "Item.itemId") 41 | .select([ 42 | "Sale.itemId as itemId", 43 | "Item.name as name", 44 | sql`SUM("Sale"."quantity")::integer`.as("quantity"), 45 | sql`SUM("Sale"."quantity" * "Sale"."unitPrice")`.as("spend"), 46 | ]) 47 | .where("Sale.date", ">=", since) 48 | .groupBy(["Sale.itemId", "Item.name"]) 49 | .orderBy("spend", "desc") 50 | .limit(10) 51 | .execute(); 52 | 53 | return results.map((r) => ({ 54 | ...r, 55 | name: r.name ?? `[${r.itemId}]`, 56 | quantity: r.quantity ?? 0, 57 | spend: r.spend ?? 0, 58 | })); 59 | } 60 | 61 | export async function getSalesHistory(itemId: number) { 62 | // This function used to take a list of item ids. I refactored it to take a single 63 | // item id to simplify its usage, but kept the SQL query the same for now. 64 | const results = await db 65 | .selectFrom("Sale") 66 | .select([ 67 | "itemId", 68 | sql`date_trunc('day', "date")::date`.as("date"), 69 | sql`SUM("quantity")::integer`.as("volume"), 70 | sql`ROUND(AVG("unitPrice"), 2)`.as("price"), 71 | ]) 72 | .where("itemId", "=", itemId) 73 | .where("Sale.date", ">=", sql`NOW() - INTERVAL '14 days'`) 74 | .groupBy(["itemId", sql`date_trunc('day', "date")::date`]) 75 | .orderBy("date", "asc") 76 | .execute(); 77 | 78 | return results.map((r) => ({ 79 | ...r, 80 | price: r.price, // already a JS number 81 | })); 82 | } 83 | 84 | export async function getItemWithSales(itemId: number, numberOfSales = 20) { 85 | const item = await db 86 | .selectFrom("Item") 87 | .selectAll() 88 | .where("Item.itemId", "=", itemId) 89 | .executeTakeFirst(); 90 | 91 | if (!item) return null; 92 | 93 | const sales = await db 94 | .selectFrom("Sale") 95 | .select([ 96 | "Sale.date as date", 97 | "Sale.unitPrice as unitPrice", 98 | "Sale.quantity as quantity", 99 | ]) 100 | .where("Sale.itemId", "=", itemId) 101 | .orderBy("Sale.date", "desc") 102 | .limit(numberOfSales) 103 | .execute(); 104 | 105 | return { 106 | ...item, 107 | sales: sales.map((s) => ({ ...s, unitPrice: Number(s.unitPrice) })), 108 | value: Number(item.value), 109 | history: await getSalesHistory(itemId), 110 | }; 111 | } 112 | 113 | export async function getTotalSales() { 114 | const { count } = await db 115 | .selectFrom("Sale") 116 | .select(({ fn }) => fn.countAll().as("count")) 117 | .executeTakeFirstOrThrow(); 118 | return count; 119 | } 120 | 121 | export async function getAllItems() { 122 | return db.selectFrom("Item").select(["itemId", "name"]).execute(); 123 | } 124 | -------------------------------------------------------------------------------- /scripts/etl.ts: -------------------------------------------------------------------------------- 1 | import { format, sub, subDays } from "date-fns"; 2 | import { createClient } from "data-of-loathing"; 3 | 4 | import { db } from "../app/db.server"; 5 | import { query } from "./econ"; 6 | import { deriveValue } from "./value"; 7 | 8 | const MIN_SALES = 20; 9 | const RECENT_CUTOFF_DAYS = 14; 10 | 11 | const dol = createClient(); 12 | 13 | function chunkArray(arr: T[], size: number): T[][] { 14 | return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => 15 | arr.slice(i * size, i * size + size), 16 | ); 17 | } 18 | 19 | async function main() { 20 | const args = process.argv.slice(2); 21 | if (args.includes("--revalue")) return await recalculateAllValues(); 22 | const itemIds = await ingestSales(); 23 | await recalculateValues(itemIds); 24 | await fetchItemData(); 25 | } 26 | 27 | async function recalculateAllValues() { 28 | const itemIds = ( 29 | await db 30 | .selectFrom("Item") 31 | .select("itemId") 32 | .orderBy("itemId", "asc") 33 | .execute() 34 | ).map((i) => i.itemId); 35 | await recalculateValues(itemIds); 36 | } 37 | 38 | async function fetchItemData() { 39 | const unknown = await db 40 | .selectFrom("Item") 41 | .select("itemId") 42 | .where("name", "is", null) 43 | .execute(); 44 | 45 | const data = ( 46 | unknown.length > 20 47 | ? (( 48 | await dol.query({ 49 | allItems: { nodes: { id: true, name: true, image: true } }, 50 | }) 51 | ).allItems?.nodes ?? []) 52 | : await Promise.all( 53 | unknown.map( 54 | async (item) => 55 | ( 56 | await dol.query({ 57 | itemById: { 58 | id: true, 59 | name: true, 60 | image: true, 61 | __args: { id: item.itemId }, 62 | }, 63 | }) 64 | ).itemById, 65 | ), 66 | ) 67 | ).filter((i) => i !== null); 68 | 69 | await db.transaction().execute(async (tx) => { 70 | for (const d of data) { 71 | await tx 72 | .updateTable("Item") 73 | .set({ name: d.name, image: d.image }) 74 | .where("itemId", "=", d.id) 75 | .execute(); 76 | } 77 | }); 78 | } 79 | 80 | async function ingestSales() { 81 | const latest = await db 82 | .selectFrom("Sale") 83 | .select("date") 84 | .orderBy("date", "desc") 85 | .limit(1) 86 | .executeTakeFirst(); 87 | 88 | const since = sub(latest?.date ?? new Date(0), { seconds: 1 }); 89 | 90 | const sales = await query(null, since); 91 | 92 | console.log( 93 | `Found ${sales.length} sales since ${format(since, "yyyy-MM-dd HH:mm:ss")}`, 94 | ); 95 | 96 | const chunks = chunkArray(sales, 1000); 97 | 98 | // Create the items first to avoid foreign key violations 99 | for (const chunk of chunks) { 100 | await db 101 | .insertInto("Item") 102 | .values( 103 | chunk.map((s) => ({ 104 | itemId: s.item, 105 | value: 0, 106 | volume: 0, 107 | date: new Date(0), 108 | })), 109 | ) 110 | .onConflict((oc) => oc.doNothing()) 111 | .execute(); 112 | } 113 | 114 | // Insert sales 115 | for (const chunk of chunks) { 116 | await db 117 | .insertInto("Sale") 118 | .values( 119 | chunk.map((s) => ({ 120 | date: s.date, 121 | unitPrice: s.unitPrice, 122 | quantity: s.quantity, 123 | source: s.source, 124 | itemId: s.item, 125 | buyerId: s.buyer, 126 | sellerId: s.seller, 127 | })), 128 | ) 129 | .onConflict((oc) => oc.doNothing()) 130 | .execute(); 131 | } 132 | 133 | return [...new Set(sales.map((s) => s.item))]; 134 | } 135 | 136 | async function getGreaterOfRecentOrMinSales( 137 | itemId: number, 138 | recentCutoff: Date, 139 | minSales: number, 140 | ) { 141 | const recent = await db 142 | .selectFrom("Sale") 143 | .selectAll() 144 | .where("itemId", "=", itemId) 145 | .where("date", ">=", recentCutoff) 146 | .orderBy("date", "desc") 147 | .execute(); 148 | 149 | if (recent.length >= minSales) { 150 | return recent; 151 | } 152 | 153 | return await db 154 | .selectFrom("Sale") 155 | .selectAll() 156 | .where("itemId", "=", itemId) 157 | .orderBy("date", "desc") 158 | .limit(minSales) 159 | .execute(); 160 | } 161 | 162 | export async function recalculateValues(itemIds: number[]) { 163 | for (const itemId of itemIds.sort((a, b) => a - b)) { 164 | console.log(`(Re)calculating value for item ${itemId}`); 165 | 166 | const now = new Date(); 167 | const recentCutoff = subDays(now, RECENT_CUTOFF_DAYS); 168 | 169 | const sales = await getGreaterOfRecentOrMinSales( 170 | itemId, 171 | recentCutoff, 172 | MIN_SALES, 173 | ); 174 | 175 | const value = deriveValue(sales); 176 | 177 | const volume = sales 178 | .filter((s) => s.date >= recentCutoff) 179 | .reduce((acc, s) => acc + s.quantity, 0); 180 | 181 | await db 182 | .insertInto("Item") 183 | .values({ 184 | itemId, 185 | value, 186 | volume, 187 | date: now, 188 | }) 189 | .onConflict((oc) => 190 | oc.column("itemId").doUpdateSet({ 191 | value, 192 | volume, 193 | date: now, 194 | }), 195 | ) 196 | .execute(); 197 | } 198 | } 199 | 200 | main(); 201 | -------------------------------------------------------------------------------- /app/components/Chart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Bar, 3 | CartesianGrid, 4 | ComposedChart, 5 | Line, 6 | ResponsiveContainer, 7 | Tooltip, 8 | XAxis, 9 | YAxis, 10 | } from "recharts"; 11 | 12 | import { useFetcher } from "react-router"; 13 | import { useEffect, useMemo } from "react"; 14 | 15 | import { type loader as itemLoader } from "../routes/api.$itemid"; 16 | import type { Item } from "./ItemSelect"; 17 | import { 18 | shortDateFormatter, 19 | numberFormatter, 20 | shortNumberFormatter, 21 | } from "~/utils"; 22 | import { RecentSales } from "./RecentSales"; 23 | 24 | const COLORS = [ 25 | "#003a7d", 26 | "#ff73b6", 27 | "#c701ff", 28 | "#4ecb8d", 29 | "#008dff", 30 | "#ff9d3a", 31 | "#f9e858", 32 | "#d83034", 33 | ]; 34 | 35 | type Props = { 36 | item: Item | null; 37 | }; 38 | 39 | export function Chart({ item }: Props) { 40 | const fetcher = useFetcher(); 41 | 42 | useEffect(() => { 43 | if (!item) return; 44 | fetcher.load(`/api/${item.itemId}`); 45 | }, [item]); 46 | 47 | const { data, series, sales } = useMemo(() => { 48 | if (item === null) return { data: [], series: [], sales: [] }; 49 | if (!fetcher.data || "error" in fetcher.data) 50 | return { data: [], series: [], sales: [] }; 51 | const itemData = Array.isArray(fetcher.data) 52 | ? fetcher.data 53 | : [fetcher.data]; 54 | 55 | const series = itemData.map((item) => ({ 56 | id: item.itemId, 57 | name: item.name ?? `Item ${item.itemId}`, 58 | image: item.image, 59 | volKey: `volume-${item.itemId}`, 60 | priceKey: `price-${item.itemId}`, 61 | })); 62 | 63 | const data = Object.values( 64 | Object.groupBy( 65 | itemData.flatMap((item) => item.history), 66 | (h) => h.date!.toISOString(), 67 | ), 68 | ) 69 | .filter((day) => day !== undefined) 70 | .map((day) => { 71 | return day.reduce( 72 | (acc, h) => ({ 73 | ...acc, 74 | [`volume-${h.itemId}`]: h.volume, 75 | [`price-${h.itemId}`]: h.price, 76 | }), 77 | { timestamp: day[0].date!.getTime(), date: day[0].date }, 78 | ); 79 | }); 80 | 81 | const sales = itemData.flatMap((i) => i.sales); 82 | 83 | return { series, data, sales }; 84 | }, [fetcher.data, item]); 85 | 86 | return ( 87 |
88 | 89 | 90 | 91 | dataMin - 24 * 60 * 60 * 1000, 97 | (dataMax: number) => dataMax + 24 * 60 * 60 * 1000, 98 | ]} 99 | tickFormatter={(iso: string) => 100 | shortDateFormatter.format(new Date(iso)) 101 | } 102 | /> 103 | shortNumberFormatter.format(v)} 108 | label={{ value: "Volume", angle: -90, position: "insideLeft" }} 109 | /> 110 | shortNumberFormatter.format(v)} 114 | label={{ value: `Price`, angle: 90, position: "insideRight" }} 115 | /> 116 | 117 | labelFormatter={(iso: string) => 118 | new Date(iso).toLocaleDateString(undefined, { 119 | weekday: "short", 120 | month: "short", 121 | day: "numeric", 122 | }) 123 | } 124 | formatter={(value, name) => [value && numberFormatter.format(value), name]} 125 | /> 126 | {series.map((s, i) => ( 127 | 135 | ))} 136 | {series.map((s, i) => ( 137 | ( 146 | 154 | )} 155 | activeDot={({ index, cx, cy }) => ( 156 | 164 | )} 165 | strokeWidth={2} 166 | /> 167 | ))} 168 | 169 | 170 | 171 |
172 | ); 173 | } 174 | -------------------------------------------------------------------------------- /scripts/value.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, beforeAll, vi, describe } from "vitest"; 2 | import { deriveValue } from "./value.js"; 3 | import type { Sale } from "~/types.js"; 4 | 5 | const baseSale = { 6 | sellerId: 1, 7 | source: "mall" as const, 8 | itemId: 1, 9 | }; 10 | const BUYER_A = 2; 11 | const BUYER_B = 3; 12 | const BUYER_C = 4; 13 | const BUYER_D = 5; 14 | 15 | describe("deriveValue", () => { 16 | beforeAll(() => { 17 | vi.setSystemTime(new Date("2024-06-01T00:00:00.000Z")); 18 | }); 19 | 20 | test("Older price drop", () => { 21 | const data: Sale[] = [ 22 | { 23 | buyerId: BUYER_A, 24 | unitPrice: 10000, 25 | quantity: 50, 26 | date: new Date("2024-05-20T00:00:00.000Z"), 27 | ...baseSale, 28 | }, 29 | { 30 | buyerId: BUYER_A, 31 | unitPrice: 11000, 32 | quantity: 100, 33 | date: new Date("2024-05-24T00:00:00.000Z"), 34 | ...baseSale, 35 | }, 36 | { 37 | buyerId: BUYER_A, 38 | unitPrice: 10000, 39 | quantity: 1, 40 | date: new Date("2024-05-26T00:00:00.000Z"), 41 | ...baseSale, 42 | }, 43 | { 44 | buyerId: BUYER_B, 45 | unitPrice: 800, 46 | quantity: 2, 47 | date: new Date("2024-05-30T00:00:00.000Z"), 48 | ...baseSale, 49 | }, 50 | { 51 | buyerId: BUYER_C, 52 | unitPrice: 700, 53 | quantity: 3, 54 | date: new Date("2024-05-31T00:00:00.000Z"), 55 | ...baseSale, 56 | }, 57 | { 58 | buyerId: BUYER_B, 59 | unitPrice: 750, 60 | quantity: 2, 61 | date: new Date("2024-05-31T12:00:00.000Z"), 62 | ...baseSale, 63 | }, 64 | { 65 | buyerId: BUYER_D, 66 | unitPrice: 750, 67 | quantity: 3, 68 | date: new Date("2024-05-31T18:00:00.000Z"), 69 | ...baseSale, 70 | }, 71 | ]; 72 | const actual = deriveValue(data); 73 | 74 | expect(actual).toEqual(3781.95); 75 | }); 76 | 77 | test("Recent price jump", () => { 78 | const data: Sale[] = [ 79 | { 80 | buyerId: BUYER_B, 81 | unitPrice: 800, 82 | quantity: 2, 83 | date: new Date("2024-05-30T00:00:00.000Z"), 84 | ...baseSale, 85 | }, 86 | { 87 | buyerId: BUYER_C, 88 | unitPrice: 700, 89 | quantity: 3, 90 | date: new Date("2024-05-31T00:00:00.000Z"), 91 | ...baseSale, 92 | }, 93 | { 94 | buyerId: BUYER_B, 95 | unitPrice: 750, 96 | quantity: 2, 97 | date: new Date("2024-05-31T12:00:00.000Z"), 98 | ...baseSale, 99 | }, 100 | { 101 | buyerId: BUYER_D, 102 | unitPrice: 750, 103 | quantity: 3, 104 | date: new Date("2024-05-31T18:00:00.000Z"), 105 | ...baseSale, 106 | }, 107 | { 108 | buyerId: BUYER_A, 109 | unitPrice: 10000, 110 | quantity: 50, 111 | date: new Date("2024-06-01T00:00:00.000Z"), 112 | ...baseSale, 113 | }, 114 | { 115 | buyerId: BUYER_A, 116 | unitPrice: 11000, 117 | quantity: 100, 118 | date: new Date("2024-06-02T00:00:00.000Z"), 119 | ...baseSale, 120 | }, 121 | { 122 | buyerId: BUYER_A, 123 | unitPrice: 10000, 124 | quantity: 1, 125 | date: new Date("2024-06-02T01:00:00.000Z"), 126 | ...baseSale, 127 | }, 128 | ]; 129 | const actual = deriveValue(data); 130 | 131 | expect(actual).toEqual(8653.07); 132 | }); 133 | 134 | test("Gradual price jump", () => { 135 | const data: Sale[] = [ 136 | { 137 | buyerId: BUYER_A, 138 | unitPrice: 500, 139 | quantity: 50, 140 | date: new Date("2024-05-20T00:00:00.000Z"), 141 | ...baseSale, 142 | }, 143 | { 144 | buyerId: BUYER_A, 145 | unitPrice: 510, 146 | quantity: 100, 147 | date: new Date("2024-05-24T00:00:00.000Z"), 148 | ...baseSale, 149 | }, 150 | { 151 | buyerId: BUYER_A, 152 | unitPrice: 520, 153 | quantity: 1, 154 | date: new Date("2024-05-26T00:00:00.000Z"), 155 | ...baseSale, 156 | }, 157 | { 158 | buyerId: BUYER_B, 159 | unitPrice: 550, 160 | quantity: 2, 161 | date: new Date("2024-05-30T00:00:00.000Z"), 162 | ...baseSale, 163 | }, 164 | { 165 | buyerId: BUYER_C, 166 | unitPrice: 580, 167 | quantity: 3, 168 | date: new Date("2024-05-31T00:00:00.000Z"), 169 | ...baseSale, 170 | }, 171 | { 172 | buyerId: BUYER_B, 173 | unitPrice: 600, 174 | quantity: 2, 175 | date: new Date("2024-05-31T12:00:00.000Z"), 176 | ...baseSale, 177 | }, 178 | { 179 | buyerId: BUYER_D, 180 | unitPrice: 625, 181 | quantity: 3, 182 | date: new Date("2024-05-31T18:00:00.000Z"), 183 | ...baseSale, 184 | }, 185 | ]; 186 | const actual = deriveValue(data); 187 | 188 | expect(actual).toEqual(568.08); 189 | }); 190 | 191 | test("Weird price jump outlier", () => { 192 | const data: Sale[] = [ 193 | { 194 | buyerId: BUYER_A, 195 | unitPrice: 500, 196 | quantity: 10, 197 | date: new Date("2024-05-20T00:00:00.000Z"), 198 | ...baseSale, 199 | }, 200 | { 201 | buyerId: BUYER_A, 202 | unitPrice: 510, 203 | quantity: 5, 204 | date: new Date("2024-05-24T00:00:00.000Z"), 205 | ...baseSale, 206 | }, 207 | { 208 | buyerId: BUYER_A, 209 | unitPrice: 520, 210 | quantity: 1, 211 | date: new Date("2024-05-26T00:00:00.000Z"), 212 | ...baseSale, 213 | }, 214 | { 215 | buyerId: BUYER_B, 216 | unitPrice: 10000, 217 | quantity: 300, 218 | date: new Date("2024-05-30T00:00:00.000Z"), 219 | ...baseSale, 220 | }, 221 | { 222 | buyerId: BUYER_C, 223 | unitPrice: 580, 224 | quantity: 3, 225 | date: new Date("2024-05-31T00:00:00.000Z"), 226 | ...baseSale, 227 | }, 228 | { 229 | buyerId: BUYER_B, 230 | unitPrice: 600, 231 | quantity: 2, 232 | date: new Date("2024-05-31T12:00:00.000Z"), 233 | ...baseSale, 234 | }, 235 | { 236 | buyerId: BUYER_D, 237 | unitPrice: 625, 238 | quantity: 3, 239 | date: new Date("2024-05-31T18:00:00.000Z"), 240 | ...baseSale, 241 | }, 242 | ]; 243 | const actual = deriveValue(data); 244 | 245 | expect(actual).toEqual(7014.53); 246 | }); 247 | 248 | test("Very low volume", () => { 249 | const data: Sale[] = [ 250 | { 251 | buyerId: BUYER_A, 252 | unitPrice: 70000, 253 | quantity: 1, 254 | date: new Date("2024-05-20T00:00:00.000Z"), 255 | ...baseSale, 256 | }, 257 | { 258 | buyerId: BUYER_D, 259 | unitPrice: 42069, 260 | quantity: 1, 261 | date: new Date("2024-05-31T18:00:00.000Z"), 262 | ...baseSale, 263 | }, 264 | ]; 265 | const actual = deriveValue(data); 266 | 267 | expect(actual).toEqual(43803.63); 268 | }); 269 | 270 | test("High volume rational market", () => { 271 | const data: Sale[] = [ 272 | { 273 | buyerId: BUYER_A, 274 | unitPrice: 7500, 275 | quantity: 780, 276 | date: new Date("2024-05-23T00:00:00.000Z"), 277 | ...baseSale, 278 | }, 279 | { 280 | buyerId: BUYER_A, 281 | unitPrice: 7600, 282 | quantity: 600, 283 | date: new Date("2024-05-24T00:00:00.000Z"), 284 | ...baseSale, 285 | }, 286 | { 287 | buyerId: BUYER_A, 288 | unitPrice: 7700, 289 | quantity: 500, 290 | date: new Date("2024-05-25T00:00:00.000Z"), 291 | ...baseSale, 292 | }, 293 | { 294 | buyerId: BUYER_B, 295 | unitPrice: 7400, 296 | quantity: 800, 297 | date: new Date("2024-05-26T00:00:00.000Z"), 298 | ...baseSale, 299 | }, 300 | { 301 | buyerId: BUYER_C, 302 | unitPrice: 7450, 303 | quantity: 750, 304 | date: new Date("2024-05-27T00:00:00.000Z"), 305 | ...baseSale, 306 | }, 307 | { 308 | buyerId: BUYER_B, 309 | unitPrice: 7500, 310 | quantity: 700, 311 | date: new Date("2024-05-28T12:00:00.000Z"), 312 | ...baseSale, 313 | }, 314 | { 315 | buyerId: BUYER_D, 316 | unitPrice: 7400, 317 | quantity: 869, 318 | date: new Date("2024-05-29T18:00:00.000Z"), 319 | ...baseSale, 320 | }, 321 | ]; 322 | const actual = deriveValue(data); 323 | 324 | expect(actual).toEqual(7471.51); 325 | }); 326 | 327 | test("High volume irrational market", () => { 328 | const data: Sale[] = [ 329 | { 330 | buyerId: BUYER_A, 331 | unitPrice: 7500, 332 | quantity: 700, 333 | date: new Date("2024-05-23T00:00:00.000Z"), 334 | ...baseSale, 335 | }, 336 | { 337 | buyerId: BUYER_A, 338 | unitPrice: 7600, 339 | quantity: 800, 340 | date: new Date("2024-05-24T00:00:00.000Z"), 341 | ...baseSale, 342 | }, 343 | { 344 | buyerId: BUYER_A, 345 | unitPrice: 7700, 346 | quantity: 900, 347 | date: new Date("2024-05-25T00:00:00.000Z"), 348 | ...baseSale, 349 | }, 350 | { 351 | buyerId: BUYER_B, 352 | unitPrice: 7400, 353 | quantity: 400, 354 | date: new Date("2024-05-26T00:00:00.000Z"), 355 | ...baseSale, 356 | }, 357 | { 358 | buyerId: BUYER_C, 359 | unitPrice: 7450, 360 | quantity: 300, 361 | date: new Date("2024-05-27T00:00:00.000Z"), 362 | ...baseSale, 363 | }, 364 | { 365 | buyerId: BUYER_B, 366 | unitPrice: 7500, 367 | quantity: 700, 368 | date: new Date("2024-05-28T12:00:00.000Z"), 369 | ...baseSale, 370 | }, 371 | { 372 | buyerId: BUYER_D, 373 | unitPrice: 7400, 374 | quantity: 500, 375 | date: new Date("2024-05-29T18:00:00.000Z"), 376 | ...baseSale, 377 | }, 378 | ]; 379 | const actual = deriveValue(data); 380 | 381 | expect(actual).toEqual(7490.53); 382 | }); 383 | 384 | test("Very old prices", () => { 385 | const data: Sale[] = [ 386 | { 387 | buyerId: BUYER_A, 388 | unitPrice: 10000, 389 | quantity: 50, 390 | date: new Date("2024-03-20T00:00:00.000Z"), 391 | ...baseSale, 392 | }, 393 | { 394 | buyerId: BUYER_A, 395 | unitPrice: 11000, 396 | quantity: 100, 397 | date: new Date("2024-03-24T00:00:00.000Z"), 398 | ...baseSale, 399 | }, 400 | { 401 | buyerId: BUYER_A, 402 | unitPrice: 10000, 403 | quantity: 1, 404 | date: new Date("2024-03-26T00:00:00.000Z"), 405 | ...baseSale, 406 | }, 407 | { 408 | buyerId: BUYER_B, 409 | unitPrice: 800, 410 | quantity: 2, 411 | date: new Date("2024-03-30T00:00:00.000Z"), 412 | ...baseSale, 413 | }, 414 | { 415 | buyerId: BUYER_C, 416 | unitPrice: 700, 417 | quantity: 3, 418 | date: new Date("2024-05-31T00:00:00.000Z"), 419 | ...baseSale, 420 | }, 421 | { 422 | buyerId: BUYER_B, 423 | unitPrice: 750, 424 | quantity: 2, 425 | date: new Date("2024-05-31T12:00:00.000Z"), 426 | ...baseSale, 427 | }, 428 | { 429 | buyerId: BUYER_D, 430 | unitPrice: 750, 431 | quantity: 3, 432 | date: new Date("2024-05-31T18:00:00.000Z"), 433 | ...baseSale, 434 | }, 435 | ]; 436 | const actual = deriveValue(data); 437 | 438 | expect(actual).toEqual(733.9); 439 | }); 440 | }); 441 | --------------------------------------------------------------------------------