Files
stock-agent/src/agent/report.ts
Gahow Wang 870b9cb2b1 feat(data): extend market data provider with charts, fundamentals, and quant analysis
Adds a stock data cache layer and indicator helpers (MA/RSI/MACD), expands
the MarketDataProvider interface with candles, fundamentals, related-data,
and quant analysis, and threads tags + source attribution through the
shared types. Updates affected agent tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:20:42 +08:00

145 lines
4.3 KiB
TypeScript

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 stockPe = snapshot.pe;
const peerPes = snapshot.peers.map((peer) => peer.pe).filter((pe) => Number.isFinite(pe) && pe > 0);
if (!Number.isFinite(stockPe) || stockPe === undefined || stockPe <= 0 || peerPes.length === 0) {
return { label: "unknown" };
}
const peerAveragePe = round(peerPes.reduce((sum, pe) => sum + pe, 0) / peerPes.length, 2);
const spreadPct = round((stockPe - peerAveragePe) / peerAveragePe, 4);
const label = spreadPct <= -0.15 ? "discount" : spreadPct >= 0.15 ? "premium" : "fair";
return {
label,
stockPe,
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;
}