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>
145 lines
4.3 KiB
TypeScript
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;
|
|
}
|