144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
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<StockSnapshot> {
|
|
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<StockSnapshot> {
|
|
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;
|
|
}
|