Skip to content

Commit 3fc0c0b

Browse files
committed
feat: portfolio report attachment + positions support in cloud
1 parent 62ff783 commit 3fc0c0b

2 files changed

Lines changed: 184 additions & 5 deletions

File tree

cloudflare/src/index.ts

Lines changed: 154 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,17 @@ async function sendMessage(token: string, chatId: number, text: string, options?
178178
}
179179
}
180180

181+
async function sendDocument(token: string, chatId: number, params: { filename: string; content: string; caption?: string }): Promise<void> {
182+
const url = `https://api.telegram.org/bot${token}/sendDocument`;
183+
const form = new FormData();
184+
form.set("chat_id", String(chatId));
185+
if (params.caption) form.set("caption", params.caption);
186+
form.set("document", new Blob([params.content], { type: "text/plain; charset=utf-8" }), params.filename);
187+
const res = await fetch(url, { method: "POST", body: form });
188+
const json = await res.json<any>();
189+
if (!json.ok) throw new Error(json.description || "Telegram sendDocument error");
190+
}
191+
181192
function stateKey(chatId: number) {
182193
return new Request(`https://state.local/pending/${chatId}`);
183194
}
@@ -481,6 +492,8 @@ type Bias = "Bullish" | "Neutral" | "Bearish";
481492
type RiskTolerance = "low" | "medium" | "high";
482493
type Horizon = "day" | "swing" | "invest";
483494

495+
type Position = { ticker: string; quantity: number; costBasis?: number };
496+
484497
function biasLabelZh(b: Bias): string {
485498
if (b === "Bullish") return "偏多";
486499
if (b === "Bearish") return "偏空";
@@ -499,6 +512,29 @@ function horizonLabelZh(h: Horizon): string {
499512
return "波段";
500513
}
501514

515+
function parsePositionsSpec(spec: string): Position[] {
516+
const raw = spec.trim();
517+
if (!raw) return [];
518+
return raw
519+
.split(",")
520+
.map((part) => part.trim())
521+
.filter(Boolean)
522+
.map((part) => {
523+
const [lhs, rhsRaw] = part.split(":");
524+
const rawTicker = (lhs || "").trim();
525+
if (!rawTicker) return null;
526+
const rhs = (rhsRaw || "").trim();
527+
const m = rhs.match(/^(\d+(?:\.\d+)?)(?:@(\d+(?:\.\d+)?))?$/);
528+
if (!m) return null;
529+
const quantity = Number(m[1]);
530+
const costBasis = m[2] != null ? Number(m[2]) : undefined;
531+
if (!Number.isFinite(quantity) || quantity <= 0) return null;
532+
if (costBasis != null && (!Number.isFinite(costBasis) || costBasis <= 0)) return null;
533+
return { ticker: normalizeTicker(rawTicker), quantity, costBasis };
534+
})
535+
.filter((x): x is Position => Boolean(x));
536+
}
537+
502538
function normalizeRisk(raw: string | undefined): RiskTolerance {
503539
const v = (raw || "").trim().toLowerCase();
504540
if (v === "low" || v === "l") return "low";
@@ -805,7 +841,104 @@ async function handle(chatId: number, text: string, env: Env): Promise<string> {
805841

806842
if (cmd === "portfolio") {
807843
const spec = (arg || (env.PORTFOLIO || "").trim()).trim();
808-
if (!spec) return "未設定 PORTFOLIO。請輸入:/portfolio NVDA,AAPL,0700.HK(或在 Worker env 設定 PORTFOLIO)";
844+
if (!spec) {
845+
return "尚未設定投資組合。\n請輸入:/portfolio NVDA,AAPL,0700.HK\n或含數量成本:/portfolio NVDA:15@167.52,0700.HK:100@493.4\n(或在 Worker env 設定 PORTFOLIO)";
846+
}
847+
848+
if (spec.includes(":")) {
849+
const positions = parsePositionsSpec(spec).slice(0, 25);
850+
if (positions.length === 0) {
851+
return "格式錯誤。範例:/portfolio NVDA:15@167.52,0700.HK:100@493.4";
852+
}
853+
854+
const rows: Array<{
855+
ticker: string;
856+
qty: number;
857+
cost?: number;
858+
price: number;
859+
mv: number;
860+
ccy: string;
861+
pnlPct?: number;
862+
bias: Bias;
863+
conf: number;
864+
rsi: number;
865+
inval: number;
866+
asOfUnix: number;
867+
}> = [];
868+
869+
let maxAsOf = 0;
870+
for (const p of positions) {
871+
const data = await fetchYahooChart(p.ticker);
872+
maxAsOf = Math.max(maxAsOf, data.asOfUnix || 0);
873+
const price = data.closes[data.closes.length - 1] || 0;
874+
const mv = price * p.quantity;
875+
const d20 = sma(data.closes, 20);
876+
const d200 = sma(data.closes, 200);
877+
const rsi = rsi14(data.closes);
878+
const hist = macdHistogram(data.closes);
879+
const { r1, s1 } = pivotsFromPreviousDay(data.highs, data.lows, data.closes);
880+
const { bias, confidence } = computeBias(price, d20, d200, hist, rsi, profile);
881+
const inval = computeInvalidation(bias, d20, r1, s1);
882+
const pnlPct =
883+
p.costBasis != null && p.costBasis > 0 ? ((price - p.costBasis) / p.costBasis) * 100 : undefined;
884+
rows.push({
885+
ticker: p.ticker,
886+
qty: p.quantity,
887+
cost: p.costBasis,
888+
price,
889+
mv,
890+
ccy: data.currency,
891+
pnlPct,
892+
bias,
893+
conf: confidence,
894+
rsi,
895+
inval,
896+
asOfUnix: data.asOfUnix,
897+
});
898+
}
899+
900+
const totalsByCcy = new Map<string, number>();
901+
for (const r of rows) totalsByCcy.set(r.ccy, (totalsByCcy.get(r.ccy) || 0) + r.mv);
902+
903+
const fmtPct = (x: number) => `${x >= 0 ? "+" : ""}${x.toFixed(2)}%`;
904+
const lines: string[] = [];
905+
lines.push("📌 投資組合摘要(含持倉/成本)");
906+
lines.push("");
907+
lines.push("🧾 PORTFOLIO SUMMARY(按幣別,不換匯)");
908+
for (const [ccy, mv] of Array.from(totalsByCcy.entries()).sort((a, b) => b[1] - a[1])) {
909+
lines.push(`- ${ccy}: ${mv.toFixed(2)}`);
910+
}
911+
lines.push("");
912+
lines.push("📋 PORTFOLIO POSITIONS");
913+
914+
const rowsByCcy = new Map<string, typeof rows>();
915+
for (const r of rows) {
916+
const arr = rowsByCcy.get(r.ccy) || [];
917+
arr.push(r);
918+
rowsByCcy.set(r.ccy, arr);
919+
}
920+
921+
for (const ccy of Array.from(rowsByCcy.keys()).sort((a, b) => a.localeCompare(b))) {
922+
const group = (rowsByCcy.get(ccy) || []).sort((a, b) => b.mv - a.mv);
923+
const totalMv = totalsByCcy.get(ccy) || 1;
924+
lines.push("");
925+
lines.push(`幣別: ${ccy}`);
926+
lines.push("Ticker | Qty | Cost | Price | MV | Weight | PnL% | Bias | Conf | RSI | Inval");
927+
for (const r of group) {
928+
const w = (r.mv / totalMv) * 100;
929+
const cost = r.cost != null ? r.cost.toFixed(2) : "-";
930+
const pnl = r.pnlPct != null ? fmtPct(r.pnlPct) : "-";
931+
lines.push(
932+
`${r.ticker} | ${r.qty} | ${cost} | ${r.price.toFixed(2)} | ${r.mv.toFixed(2)} | ${w.toFixed(1)}% | ${pnl} | ${biasLabelZh(r.bias)} | ${r.conf}% | ${r.rsi.toFixed(1)} | ${r.inval.toFixed(2)}`,
933+
);
934+
}
935+
}
936+
937+
lines.push("");
938+
lines.push(formatFooter(profile, maxAsOf));
939+
return lines.join("\n");
940+
}
941+
809942
const tickers = spec.split(",").map((s) => normalizeTicker(s)).filter(Boolean);
810943
const briefs: string[] = [];
811944
let maxAsOf = 0;
@@ -1055,8 +1188,16 @@ export default {
10551188
: !text.startsWith("/") ? text : "";
10561189
const tickerForButtons = tickerForButtonsRaw.includes(",") ? tickerForButtonsRaw.split(",")[0] : tickerForButtonsRaw;
10571190
const replyMarkup = tickerForButtons ? buildInlineActions(normalizeTicker(tickerForButtons)) : undefined;
1058-
if (text === "/help" || text === "/start") ctx.waitUntil(sendMessage(token, chatId, body, { replyMarkup: buildHelpMenu() }));
1059-
else ctx.waitUntil(sendMessage(token, chatId, body, replyMarkup ? { replyMarkup } : undefined));
1191+
if (text.startsWith("/portfolio")) {
1192+
const preview = body.split("\n").slice(0, 26).join("\n") + "\n\n(完整報告已輸出附件檔)";
1193+
ctx.waitUntil(sendMessage(token, chatId, preview, { replyMarkup: buildHelpMenu() }));
1194+
const filename = `portfolio_${new Date().toISOString().slice(0, 10)}.txt`;
1195+
ctx.waitUntil(sendDocument(token, chatId, { filename, content: body, caption: "Portfolio report" }));
1196+
} else if (text === "/help" || text === "/start") {
1197+
ctx.waitUntil(sendMessage(token, chatId, body, { replyMarkup: buildHelpMenu() }));
1198+
} else {
1199+
ctx.waitUntil(sendMessage(token, chatId, body, replyMarkup ? { replyMarkup } : undefined));
1200+
}
10601201
return new Response("OK", { status: 200 });
10611202
}
10621203

@@ -1081,8 +1222,16 @@ export default {
10811222
: !text.startsWith("/") ? text : "";
10821223
const tickerForButtons = tickerForButtonsRaw.includes(",") ? tickerForButtonsRaw.split(",")[0] : tickerForButtonsRaw;
10831224
const replyMarkup = tickerForButtons ? buildInlineActions(normalizeTicker(tickerForButtons)) : undefined;
1084-
if (text === "/help" || text === "/start") ctx.waitUntil(sendMessage(token, chatId, reply, { replyMarkup: buildHelpMenu() }));
1085-
else ctx.waitUntil(sendMessage(token, chatId, reply, replyMarkup ? { replyMarkup } : undefined));
1225+
if (text.startsWith("/portfolio")) {
1226+
const preview = reply.split("\n").slice(0, 26).join("\n") + "\n\n(完整報告已輸出附件檔)";
1227+
ctx.waitUntil(sendMessage(token, chatId, preview, { replyMarkup: buildHelpMenu() }));
1228+
const filename = `portfolio_${new Date().toISOString().slice(0, 10)}.txt`;
1229+
ctx.waitUntil(sendDocument(token, chatId, { filename, content: reply, caption: "Portfolio report" }));
1230+
} else if (text === "/help" || text === "/start") {
1231+
ctx.waitUntil(sendMessage(token, chatId, reply, { replyMarkup: buildHelpMenu() }));
1232+
} else {
1233+
ctx.waitUntil(sendMessage(token, chatId, reply, replyMarkup ? { replyMarkup } : undefined));
1234+
}
10861235
ctx.waitUntil(
10871236
caches.default.put(cacheKey, new Response(reply, { headers: { "cache-control": "max-age=90" } })),
10881237
);

scripts/telegram-bot.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,31 @@ function extractBrief(stdout: string): string {
179179
return out.slice(0, 6).join("\n");
180180
}
181181

182+
function extractPortfolioBrief(text: string): string {
183+
const lines = text.split(/\r?\n/);
184+
const clean = lines.map((l) => l.replace(/\s+$/g, ""));
185+
const out: string[] = [];
186+
187+
const findSection = (needle: string) => clean.findIndex((l) => l.includes(needle));
188+
const pushBlock = (startIdx: number, maxLines: number) => {
189+
if (startIdx < 0) return;
190+
for (let i = startIdx; i < clean.length && out.length < maxLines; i++) {
191+
const s = clean[i];
192+
out.push(s);
193+
if (i > startIdx && s.trim() === "") break;
194+
}
195+
};
196+
197+
pushBlock(findSection("PORTFOLIO SUMMARY"), 18);
198+
if (out.length) out.push("");
199+
pushBlock(findSection("PORTFOLIO VERDICT"), 14);
200+
if (out.length) out.push("");
201+
const posIdx = findSection("PORTFOLIO POSITIONS");
202+
if (posIdx >= 0) out.push("PORTFOLIO POSITIONS(請看附件完整表格)");
203+
204+
return out.map((l) => l.trimEnd()).join("\n").trim();
205+
}
206+
182207
type MarkSixCache = { ts: number; text: string };
183208
const marksixCache = new Map<number, MarkSixCache>();
184209
const MARKSIX_CACHE_TTL_MS = 60_000;
@@ -525,6 +550,11 @@ async function handleMessage(chatId: number, text: string): Promise<void> {
525550
await sendMessage(chatId, `❌ Failed:\n\`\`\`\n${escapeMarkdown(cached.stderr || cached.stdout || "unknown error")}\n\`\`\``);
526551
return;
527552
}
553+
try {
554+
const fileText = (await readFile(cached.outPath)).toString();
555+
const brief = extractPortfolioBrief(fileText);
556+
if (brief) await sendMessage(chatId, `\`\`\`\n${escapeMarkdown(brief)}\n\`\`\``, { replyMarkup: buildInlineActions({}) });
557+
} catch {}
528558
await sendDocument(chatId, cached.outPath, cached.hit ? "Portfolio summary (cached)" : "Portfolio summary");
529559
await sendMessage(chatId, "✅ Portfolio ready.", { replyMarkup: buildInlineActions({}) });
530560
return;

0 commit comments

Comments
 (0)