@@ -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+
181192function stateKey ( chatId : number ) {
182193 return new Request ( `https://state.local/pending/${ chatId } ` ) ;
183194}
@@ -481,6 +492,8 @@ type Bias = "Bullish" | "Neutral" | "Bearish";
481492type RiskTolerance = "low" | "medium" | "high" ;
482493type Horizon = "day" | "swing" | "invest" ;
483494
495+ type Position = { ticker : string ; quantity : number ; costBasis ?: number } ;
496+
484497function 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+
502538function 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 ) ;
0 commit comments