import { z } from "zod"; import type { MarketDataProvider, StockSnapshot, WatchStock } from "../shared/types"; type FetchLike = typeof fetch; export const IPADS_BASE_URL = "http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1"; export const IPADS_WIRE_API = "responses"; export const IPADS_MODEL = "gpt-5.5"; const snapshotSchema = z.object({ price: z.number(), currency: z.string().min(1), pe: z.number(), forwardPe: z.number().optional(), revenueGrowth: z.number().optional(), profitGrowth: z.number().optional(), nextEarningsDate: z.string().optional(), peers: z.array(z.object({ display: z.string(), pe: z.number() })).default([]), news: z.array(z.object({ title: z.string(), url: z.string(), source: z.string().optional(), publishedAt: z.string().optional() })).default([]) }); const responsesOutputSchema = z.object({ output_text: z.string().optional(), output: z .array( z.object({ content: z .array( z.object({ type: z.string().optional(), text: z.string().optional() }) ) .optional() }) ) .optional() }); export class IpadsResponsesProvider implements MarketDataProvider { constructor( private readonly apiKey: string, private readonly fetchImpl: FetchLike = fetch ) {} async snapshot(stock: WatchStock): Promise { const response = await this.fetchImpl(`${IPADS_BASE_URL}/${IPADS_WIRE_API}`, { method: "POST", headers: { Accept: "application/json", Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: IPADS_MODEL, input: buildSnapshotPrompt(stock) }) }); if (!response.ok) { throw new Error(`IPADS Responses request failed: ${response.status}`); } return parseResponsesSnapshot(await response.json()); } } function buildSnapshotPrompt(stock: WatchStock): string { return [ "Return only a valid JSON object for a stock monitoring snapshot.", "Use this exact schema:", '{"price": 100, "currency": "USD", "pe": 20, "forwardPe": 18, "revenueGrowth": 0.08, "profitGrowth": 0.05, "nextEarningsDate": "2026-05-13", "peers": [{"display": "MSFT", "pe": 28}], "news": [{"title": "Earnings preview", "url": "https://example.com/news"}]}', "All numeric fields must be numbers, not null. If precise market data is unavailable, provide a conservative approximate monitoring snapshot and keep news empty.", `Stock request: ${JSON.stringify({ symbol: stock.display, market: stock.market, name: stock.name })}` ].join("\n"); } export class DemoMarketDataProvider implements MarketDataProvider { async snapshot(stock: WatchStock): Promise { const seed = stock.display.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0); const marketCurrency = stock.market === "US" ? "USD" : stock.market === "HK" ? "HKD" : "CNY"; const pe = round(10 + (seed % 24), 2); const peerBase = pe + ((seed % 2 === 0 ? 1 : -1) * (4 + (seed % 6))); const nextEarnings = new Date(Date.UTC(2026, 4, 11 + (seed % 21))).toISOString().slice(0, 10); return { price: round(20 + (seed % 180) + (seed % 13) / 10, 2), currency: marketCurrency, pe, forwardPe: round(pe * 0.92, 2), revenueGrowth: round(((seed % 18) - 4) / 100, 4), profitGrowth: round(((seed % 16) - 6) / 100, 4), nextEarningsDate: nextEarnings, peers: [ { display: peerDisplay(stock, 1), pe: round(peerBase, 2) }, { display: peerDisplay(stock, 2), pe: round(peerBase + 2.3, 2) } ], news: [ { title: `${stock.display} related sector signal updated`, url: `https://example.local/news/${encodeURIComponent(stock.display)}` } ] }; } } export function createProvider(apiKey: string): MarketDataProvider { if (apiKey.trim()) { return new IpadsResponsesProvider(apiKey.trim()); } return new DemoMarketDataProvider(); } function parseResponsesSnapshot(payload: unknown): StockSnapshot { const response = responsesOutputSchema.parse(payload); const text = response.output_text ?? response.output?.flatMap((item) => item.content ?? []).find((content) => content.text)?.text; if (!text) { throw new Error("IPADS Responses payload did not include output text"); } return snapshotSchema.parse(JSON.parse(stripJsonFence(text))); } function stripJsonFence(text: string): string { const trimmed = text.trim(); const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/); return fenced?.[1] ?? trimmed; } function peerDisplay(stock: WatchStock, index: number): string { if (stock.market === "HK") { return `${String(9000 + index).padStart(5, "0")}.HK`; } if (stock.market === "CN") { return `${600000 + index}.SS`; } return index === 1 ? "MSFT" : "GOOGL"; } function round(value: number, digits: number): number { const factor = 10 ** digits; return Math.round(value * factor) / factor; }