feat: bootstrap personal stock agent
This commit is contained in:
143
src/agent/provider.ts
Normal file
143
src/agent/provider.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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;
|
||||
}
|
||||
143
src/agent/report.ts
Normal file
143
src/agent/report.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
DailyReport,
|
||||
DailyReportItem,
|
||||
EarningsSignal,
|
||||
Market,
|
||||
MarketDataProvider,
|
||||
Recommendation,
|
||||
StockSnapshot,
|
||||
ValuationSignal,
|
||||
WatchStock
|
||||
} from "../shared/types";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function generateDailyReport(
|
||||
watchlist: WatchStock[],
|
||||
provider: MarketDataProvider,
|
||||
now = new Date()
|
||||
): Promise<DailyReport> {
|
||||
const items = await Promise.all(
|
||||
watchlist.map(async (stock) => buildReportItem(stock, await provider.snapshot(stock), now))
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
summary: buildSummary(items),
|
||||
items: items.sort((left, right) => rankItem(right) - rankItem(left))
|
||||
};
|
||||
}
|
||||
|
||||
function buildReportItem(stock: WatchStock, snapshot: StockSnapshot, now: Date): DailyReportItem {
|
||||
const earnings = analyzeEarnings(snapshot, now);
|
||||
const valuation = analyzeValuation(snapshot);
|
||||
const recommendation = recommend(snapshot, valuation, earnings);
|
||||
|
||||
return {
|
||||
stock,
|
||||
snapshot,
|
||||
earnings,
|
||||
valuation,
|
||||
recommendation,
|
||||
news: snapshot.news
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeEarnings(snapshot: StockSnapshot, now: Date): EarningsSignal {
|
||||
if (!snapshot.nextEarningsDate) {
|
||||
return { status: "none" };
|
||||
}
|
||||
|
||||
const date = new Date(`${snapshot.nextEarningsDate}T00:00:00.000Z`);
|
||||
const start = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const daysUntil = Math.ceil((date.getTime() - start) / DAY_MS);
|
||||
|
||||
return {
|
||||
status: daysUntil >= 0 && daysUntil <= 14 ? "soon" : "scheduled",
|
||||
nextDate: snapshot.nextEarningsDate,
|
||||
daysUntil
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeValuation(snapshot: StockSnapshot): ValuationSignal {
|
||||
const peerPes = snapshot.peers.map((peer) => peer.pe).filter((pe) => Number.isFinite(pe) && pe > 0);
|
||||
if (!Number.isFinite(snapshot.pe) || snapshot.pe <= 0 || peerPes.length === 0) {
|
||||
return { label: "unknown" };
|
||||
}
|
||||
|
||||
const peerAveragePe = round(peerPes.reduce((sum, pe) => sum + pe, 0) / peerPes.length, 2);
|
||||
const spreadPct = round((snapshot.pe - peerAveragePe) / peerAveragePe, 4);
|
||||
const label = spreadPct <= -0.15 ? "discount" : spreadPct >= 0.15 ? "premium" : "fair";
|
||||
|
||||
return {
|
||||
label,
|
||||
stockPe: snapshot.pe,
|
||||
peerAveragePe,
|
||||
spreadPct
|
||||
};
|
||||
}
|
||||
|
||||
function recommend(snapshot: StockSnapshot, valuation: ValuationSignal, earnings: EarningsSignal): Recommendation {
|
||||
const revenueGrowth = snapshot.revenueGrowth ?? 0;
|
||||
const profitGrowth = snapshot.profitGrowth ?? 0;
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (valuation.label === "discount") {
|
||||
reasons.push("估值低于可比股票均值");
|
||||
}
|
||||
if (valuation.label === "premium") {
|
||||
reasons.push("估值高于可比股票均值");
|
||||
}
|
||||
if (revenueGrowth > 0) {
|
||||
reasons.push("收入仍在增长");
|
||||
}
|
||||
if (profitGrowth > 0) {
|
||||
reasons.push("利润仍在增长");
|
||||
}
|
||||
if (earnings.status === "soon") {
|
||||
reasons.push("未来 14 天内有财报事件");
|
||||
}
|
||||
|
||||
if (valuation.label === "discount" && revenueGrowth > 0 && profitGrowth >= 0) {
|
||||
return { action: "buy_watch", confidence: 0.72, reasons };
|
||||
}
|
||||
|
||||
if (valuation.label === "premium" && revenueGrowth < 0 && profitGrowth < 0) {
|
||||
return { action: "avoid", confidence: 0.68, reasons };
|
||||
}
|
||||
|
||||
return {
|
||||
action: "hold",
|
||||
confidence: earnings.status === "soon" ? 0.58 : 0.52,
|
||||
reasons: reasons.length ? reasons : ["等待更明确的基本面或估值信号"]
|
||||
};
|
||||
}
|
||||
|
||||
function buildSummary(items: DailyReportItem[]): string {
|
||||
const counts = new Map<Market, number>([
|
||||
["CN", 0],
|
||||
["HK", 0],
|
||||
["US", 0]
|
||||
]);
|
||||
for (const item of items) {
|
||||
counts.set(item.stock.market, (counts.get(item.stock.market) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const soon = items.filter((item) => item.earnings.status === "soon").length;
|
||||
const discounts = items.filter((item) => item.valuation.label === "discount").length;
|
||||
return `CN: ${counts.get("CN")}; HK: ${counts.get("HK")}; US: ${counts.get("US")}; earnings soon: ${soon}; valuation discounts: ${discounts}`;
|
||||
}
|
||||
|
||||
function rankItem(item: DailyReportItem): number {
|
||||
return (
|
||||
(item.earnings.status === "soon" ? 4 : 0) +
|
||||
(item.valuation.label === "discount" ? 3 : 0) +
|
||||
(item.recommendation.action === "buy_watch" ? 2 : 0) +
|
||||
item.news.length
|
||||
);
|
||||
}
|
||||
|
||||
function round(value: number, digits: number): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
51
src/agent/symbols.ts
Normal file
51
src/agent/symbols.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Market, NormalizedSymbol } from "../shared/types";
|
||||
|
||||
const CN_SUFFIXES = new Set(["SS", "SH", "SZ"]);
|
||||
|
||||
export function normalizeSymbol(input: string): NormalizedSymbol {
|
||||
const raw = input.trim().toUpperCase();
|
||||
if (!raw) {
|
||||
throw new Error("Stock symbol is required");
|
||||
}
|
||||
|
||||
const suffixMatch = raw.match(/^([A-Z0-9]+)\.([A-Z]{2})$/);
|
||||
if (suffixMatch) {
|
||||
const [, base, suffix] = suffixMatch;
|
||||
if (CN_SUFFIXES.has(suffix)) {
|
||||
return {
|
||||
symbol: base,
|
||||
market: "CN",
|
||||
display: `${base}.${suffix === "SH" ? "SS" : suffix}`
|
||||
};
|
||||
}
|
||||
if (suffix === "HK" && /^\d{4,5}$/.test(base)) {
|
||||
return { symbol: base.padStart(5, "0"), market: "HK", display: `${base.padStart(5, "0")}.HK` };
|
||||
}
|
||||
if (suffix === "US" && /^[A-Z]{1,6}$/.test(base)) {
|
||||
return { symbol: base, market: "US", display: base };
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\d{6}$/.test(raw)) {
|
||||
return { symbol: raw, market: "CN", display: `${raw}.${inferChinaExchange(raw)}` };
|
||||
}
|
||||
|
||||
if (/^\d{4,5}$/.test(raw)) {
|
||||
const symbol = raw.padStart(5, "0");
|
||||
return { symbol, market: "HK", display: `${symbol}.HK` };
|
||||
}
|
||||
|
||||
if (/^[A-Z]{1,6}$/.test(raw)) {
|
||||
return { symbol: raw, market: "US", display: raw };
|
||||
}
|
||||
|
||||
throw new Error("Unsupported stock symbol");
|
||||
}
|
||||
|
||||
function inferChinaExchange(symbol: string): "SS" | "SZ" {
|
||||
return symbol.startsWith("6") ? "SS" : "SZ";
|
||||
}
|
||||
|
||||
export function marketLabel(market: Market): string {
|
||||
return market === "CN" ? "A股" : market === "HK" ? "港股" : "美股";
|
||||
}
|
||||
58
src/agent/watchlistStore.ts
Normal file
58
src/agent/watchlistStore.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { normalizeSymbol } from "./symbols";
|
||||
import type { WatchStock } from "../shared/types";
|
||||
|
||||
export class WatchlistStore {
|
||||
constructor(private readonly path: string) {}
|
||||
|
||||
async list(): Promise<WatchStock[]> {
|
||||
return this.read();
|
||||
}
|
||||
|
||||
async add(symbolInput: string, name?: string): Promise<WatchStock> {
|
||||
const normalized = normalizeSymbol(symbolInput);
|
||||
const stocks = await this.read();
|
||||
const existing = stocks.find((stock) => stock.display === normalized.display);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const stock: WatchStock = {
|
||||
...normalized,
|
||||
id: normalized.display,
|
||||
name: name?.trim() || undefined,
|
||||
addedAt: new Date().toISOString()
|
||||
};
|
||||
await this.write([...stocks, stock]);
|
||||
return stock;
|
||||
}
|
||||
|
||||
async remove(symbolInput: string): Promise<void> {
|
||||
const normalized = normalizeSymbol(symbolInput);
|
||||
const stocks = await this.read();
|
||||
await this.write(stocks.filter((stock) => stock.display !== normalized.display));
|
||||
}
|
||||
|
||||
private async read(): Promise<WatchStock[]> {
|
||||
try {
|
||||
const content = await readFile(this.path, "utf8");
|
||||
const parsed = JSON.parse(content) as WatchStock[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async write(stocks: WatchStock[]): Promise<void> {
|
||||
await mkdir(dirname(this.path), { recursive: true });
|
||||
await writeFile(this.path, `${JSON.stringify(stocks, null, 2)}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
Reference in New Issue
Block a user