diff --git a/.gitignore b/.gitignore index fdf8b5e9..43ed0100 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,8 @@ packages/ibkr/ref/samples/Java/ packages/ibkr/ref/samples/Cpp/ packages/ibkr/ref/CMakeLists.txt +# Local services (cloned repos) +services/ + # Turborepo .turbo/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d061825..0147f135 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,6 +216,9 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 + lightweight-charts: + specifier: ^5.1.0 + version: 5.1.0 marked: specifier: ^15.0.12 version: 15.0.12 @@ -1913,6 +1916,9 @@ packages: extend@2.0.2: resolution: {integrity: sha512-AgFD4VU+lVLP6vjnlNfF7OeInLTyeyckCNPEsuxz1vi786UuK/nk6ynPuhn/h+Ju9++TQyr5EpLRI14fc1QtTQ==} + fancy-canvas@2.1.0: + resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2226,6 +2232,9 @@ packages: resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} + lightweight-charts@5.1.0: + resolution: {integrity: sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -4641,6 +4650,8 @@ snapshots: extend@2.0.2: {} + fancy-canvas@2.1.0: {} + fast-deep-equal@3.1.3: {} fast-uri@3.1.0: {} @@ -4926,6 +4937,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.31.1 lightningcss-win32-x64-msvc: 1.31.1 + lightweight-charts@5.1.0: + dependencies: + fancy-canvas: 2.1.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index d4ce05d1..7a1dac3f 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -122,6 +122,8 @@ export async function askAgentSdk( const isOAuthMode = loginMethod === 'claudeai' const env: Record = { ...process.env } + // Prevent "nested session" detection when launched from within Claude Code + delete env.CLAUDECODE if (isOAuthMode) { // Force OAuth by removing any inherited API key delete env.ANTHROPIC_API_KEY diff --git a/src/domain/fugle/config.ts b/src/domain/fugle/config.ts new file mode 100644 index 00000000..5c52bf5d --- /dev/null +++ b/src/domain/fugle/config.ts @@ -0,0 +1,32 @@ +/** + * Fugle config — reads from data/config/fugle.json. + * API key and MCP URL are kept in gitignored config file. + */ + +import { readFile, writeFile, mkdir } from 'fs/promises' +import { resolve } from 'path' +import { z } from 'zod' + +const CONFIG_PATH = resolve('data/config/fugle.json') + +const fugleConfigSchema = z.object({ + enabled: z.boolean().default(true), + mcpUrl: z.string().default(''), +}) + +export type FugleConfig = z.infer + +export async function readFugleConfig(): Promise { + try { + const raw = JSON.parse(await readFile(CONFIG_PATH, 'utf-8')) + return fugleConfigSchema.parse(raw) + } catch (err: unknown) { + if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { + const defaults = fugleConfigSchema.parse({}) + await mkdir(resolve('data/config'), { recursive: true }) + await writeFile(CONFIG_PATH, JSON.stringify(defaults, null, 2) + '\n') + return defaults + } + return fugleConfigSchema.parse({}) + } +} diff --git a/src/domain/twstock/client.ts b/src/domain/twstock/client.ts new file mode 100644 index 00000000..6c79a8d8 --- /dev/null +++ b/src/domain/twstock/client.ts @@ -0,0 +1,68 @@ +/** + * TwstockMcpClient — MCP client wrapper for the remote twstock server. + * + * Lazy-connects on first tool call so OpenAlice starts even if the server is down. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +export class TwstockMcpClient { + private client: Client | null = null + private connecting: Promise | null = null + + constructor(private mcpUrl: string) {} + + private async ensureConnected(): Promise { + if (this.client) return this.client + + // Guard against concurrent connection attempts + if (this.connecting) return this.connecting + + this.connecting = (async () => { + const transport = new StreamableHTTPClientTransport(new URL(this.mcpUrl)) + const client = new Client({ name: 'open-alice', version: '1.0.0' }) + await client.connect(transport) + this.client = client + return client + })() + + try { + return await this.connecting + } catch (err) { + // Reset so the next call retries + this.client = null + throw err + } finally { + this.connecting = null + } + } + + /** Call a remote twstock MCP tool by name. */ + async callTool(toolName: string, args: Record = {}): Promise { + const client = await this.ensureConnected() + const result = await client.callTool({ name: toolName, arguments: args }) + + if (result.isError) { + const text = extractText(result.content) + throw new Error(text || 'twstock MCP tool returned an error') + } + + const text = extractText(result.content) + // Try to parse as JSON; return raw string if not valid JSON + try { return JSON.parse(text) } catch { return text } + } + + async close(): Promise { + await this.client?.close() + this.client = null + } +} + +function extractText(content: unknown): string { + if (!Array.isArray(content)) return String(content ?? '') + return content + .filter((b: { type: string }) => b.type === 'text') + .map((b: { text: string }) => b.text) + .join('\n') +} diff --git a/src/domain/twstock/config.ts b/src/domain/twstock/config.ts new file mode 100644 index 00000000..aa32bc5d --- /dev/null +++ b/src/domain/twstock/config.ts @@ -0,0 +1,35 @@ +/** + * Twstock config — standalone reader for data/config/twstock.json. + * + * The MCP URL is kept in the config file (gitignored) to avoid + * leaking the endpoint in source code. + */ + +import { readFile, writeFile, mkdir } from 'fs/promises' +import { resolve } from 'path' +import { z } from 'zod' + +const CONFIG_PATH = resolve('data/config/twstock.json') + +const twstockConfigSchema = z.object({ + enabled: z.boolean().default(true), + mcpUrl: z.string().default(''), +}) + +export type TwstockConfig = z.infer + +/** Read twstock config from disk. Seeds defaults if file is missing. */ +export async function readTwstockConfig(): Promise { + try { + const raw = JSON.parse(await readFile(CONFIG_PATH, 'utf-8')) + return twstockConfigSchema.parse(raw) + } catch (err: unknown) { + if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { + const defaults = twstockConfigSchema.parse({}) + await mkdir(resolve('data/config'), { recursive: true }) + await writeFile(CONFIG_PATH, JSON.stringify(defaults, null, 2) + '\n') + return defaults + } + return twstockConfigSchema.parse({}) + } +} diff --git a/src/main.ts b/src/main.ts index 9f3421ce..9d27f016 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,6 +41,11 @@ import { createCronEngine, createCronListener, createCronTools } from './task/cr import { createHeartbeat } from './task/heartbeat/index.js' import { NewsCollectorStore, NewsCollector } from './domain/news/index.js' import { createNewsArchiveTools } from './tool/news.js' +import { TwstockMcpClient } from './domain/twstock/client.js' +import { createTwstockTools } from './tool/twstock.js' +import { readTwstockConfig } from './domain/twstock/config.js' +import { createFugleTools } from './tool/fugle.js' +import { readFugleConfig } from './domain/fugle/config.js' // ==================== Persistence paths ==================== @@ -211,6 +216,22 @@ async function main() { } toolCenter.register(createAnalysisTools(equityClient, cryptoClient, currencyClient, commodityClient), 'analysis') + // Taiwan stock market tools (remote MCP) + const twstockConfig = await readTwstockConfig() + let twstockClient: TwstockMcpClient | null = null + if (twstockConfig.enabled && twstockConfig.mcpUrl) { + twstockClient = new TwstockMcpClient(twstockConfig.mcpUrl) + toolCenter.register(createTwstockTools(twstockClient), 'twstock') + } + + // Fugle market data tools (remote MCP) + const fugleConfig = await readFugleConfig() + let fugleClient: TwstockMcpClient | null = null + if (fugleConfig.enabled && fugleConfig.mcpUrl) { + fugleClient = new TwstockMcpClient(fugleConfig.mcpUrl) + toolCenter.register(createFugleTools(fugleClient), 'fugle') + } + console.log(`tool-center: ${toolCenter.list().length} tools registered`) // ==================== AI Provider Chain ==================== @@ -428,6 +449,8 @@ async function main() { await toolCallLog.close() await eventLog.close() await accountManager.closeAll() + await twstockClient?.close() + await fugleClient?.close() process.exit(0) } process.on('SIGINT', shutdown) diff --git a/src/tool/fugle.ts b/src/tool/fugle.ts new file mode 100644 index 00000000..40198f82 --- /dev/null +++ b/src/tool/fugle.ts @@ -0,0 +1,135 @@ +/** + * Fugle Market Data AI Tools + * + * Intraday candles (1/3/5/10/15/30/60 min), historical candles (D/W/M), + * real-time quotes, and tick-by-tick trades via Fugle API. + */ + +import { tool } from 'ai' +import { z } from 'zod' +import type { TwstockMcpClient } from '@/domain/twstock/client' + +export function createFugleTools(client: TwstockMcpClient) { + const call = async (name: string, args: Record = {}) => { + try { + return await client.callTool(name, args) + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) } + } + } + + return { + fugleGetIntradayCandles: tool({ + description: `Get intraday K-line candlestick data for a TWSE/TPEx stock via Fugle. + +Returns OHLCV candles for today's trading session at the specified interval. +Timeframes: 1, 3, 5, 10, 15, 30, 60 (minutes). +Use this for intraday chart analysis — much more granular than daily data. + +IMPORTANT: When presenting K-line data, wrap the JSON in a \`\`\`kline code block.`, + inputSchema: z.object({ + symbol: z.string().describe('Stock code, e.g. "2330"'), + timeframe: z.string().optional().describe('Candle interval in minutes: 1, 3, 5, 10, 15, 30, 60 (default: 5)'), + }), + execute: async ({ symbol, timeframe }) => { + const args: Record = { symbol } + if (timeframe) args.timeframe = timeframe + return call('get_intraday_candles', args) + }, + }), + + fugleGetIntradayQuote: tool({ + description: 'Get real-time intraday quote for a stock via Fugle. Returns current price, change, best 5 bid/ask, volume, and last trade info.', + inputSchema: z.object({ + symbol: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ symbol }) => call('get_intraday_quote', { symbol }), + }), + + fugleGetIntradayTrades: tool({ + description: 'Get intraday tick-by-tick trade data for a stock via Fugle.', + inputSchema: z.object({ + symbol: z.string().describe('Stock code, e.g. "2330"'), + limit: z.number().int().optional().describe('Max trades to return (default: 50)'), + }), + execute: async ({ symbol, limit }) => { + const args: Record = { symbol } + if (limit) args.limit = limit + return call('get_intraday_trades', args) + }, + }), + + fugleGetHistoricalCandles: tool({ + description: `Get historical K-line candlestick data for a stock via Fugle. + +Supports: 1/3/5/10/15/30/60 min, D (daily), W (weekly), M (monthly). +Use for multi-timeframe technical analysis. + +IMPORTANT: When presenting K-line data, wrap the JSON in a \`\`\`kline code block.`, + inputSchema: z.object({ + symbol: z.string().describe('Stock code, e.g. "2330"'), + timeframe: z.string().optional().describe('Period: 1/3/5/10/15/30/60/D/W/M (default: D)'), + from_date: z.string().optional().describe('Start date (YYYY-MM-DD)'), + to_date: z.string().optional().describe('End date (YYYY-MM-DD)'), + }), + execute: async ({ symbol, timeframe, from_date, to_date }) => { + const args: Record = { symbol } + if (timeframe) args.timeframe = timeframe + if (from_date) args.from_date = from_date + if (to_date) args.to_date = to_date + return call('get_historical_candles', args) + }, + }), + + fugleGetHistoricalStats: tool({ + description: 'Get historical statistics for a stock (52-week high/low, averages, etc.) via Fugle.', + inputSchema: z.object({ + symbol: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ symbol }) => call('get_historical_stats', { symbol }), + }), + + // ==================== Ticker & Volumes ==================== + + fugleGetIntradayTicker: tool({ + description: 'Get basic stock information (name, reference price, limit up/down, security type, day-trade eligibility) via Fugle.', + inputSchema: z.object({ + symbol: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ symbol }) => call('get_intraday_ticker', { symbol }), + }), + + fugleGetIntradayVolumes: tool({ + description: 'Get intraday price-volume distribution table showing cumulative volume at each price level.', + inputSchema: z.object({ + symbol: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ symbol }) => call('get_intraday_volumes', { symbol }), + }), + + // ==================== Volume Monitoring ==================== + + fugleCheckVolumeSpikes: tool({ + description: `Check multiple stocks for volume spikes in today's intraday data. + +Compares the latest candle volume against the average of previous candles. +Returns alerts for stocks exceeding the threshold (default: 2x average). + +Can be scheduled via cron for continuous monitoring during market hours. +Example: check 2330,2317,2454 every 5 minutes for 2x volume spikes.`, + inputSchema: z.object({ + symbols: z.string().describe('Comma-separated stock codes, e.g. "2330,2317,2454,2382"'), + timeframe: z.string().optional().describe('Candle interval in minutes (default: 5)'), + threshold: z.number().optional().describe('Volume spike multiplier (default: 2.0 = 2x average)'), + }), + execute: async ({ symbols, timeframe, threshold }) => { + const args: Record = { symbols } + if (timeframe) args.timeframe = timeframe + if (threshold) args.threshold = threshold + return call('check_volume_spikes', args) + }, + }), + + // Snapshot tools (movers, actives) require Fugle Developer plan — not registered for basic users. + } +} diff --git a/src/tool/twstock.ts b/src/tool/twstock.ts new file mode 100644 index 00000000..309f0273 --- /dev/null +++ b/src/tool/twstock.ts @@ -0,0 +1,305 @@ +/** + * Taiwan Stock Market AI Tools (twstock) + * + * Thin bridge to the remote twstock MCP server, exposing TWSE data as + * OpenAlice tools for the AI agent. Covers company fundamentals, trading + * data, market indices, foreign investment, warrants, and ESG. + */ + +import { tool } from 'ai' +import { z } from 'zod' +import type { TwstockMcpClient } from '@/domain/twstock/client' + +export function createTwstockTools(client: TwstockMcpClient) { + // Helper: wrap callTool with error handling matching project conventions + const call = async (name: string, args: Record = {}) => { + try { + return await client.callTool(name, args) + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) } + } + } + + return { + // ==================== Company Info ==================== + + twstockGetCompanyProfile: tool({ + description: 'Get basic information of a TWSE-listed company (name, industry, capital, chairman, etc.) by stock code.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330" for TSMC'), + }), + execute: async ({ code }) => call('get_company_profile', { code }), + }), + + twstockGetCompanyBalanceSheet: tool({ + description: 'Get balance sheet for a TWSE-listed company. Auto-detects industry format (general, financial, insurance, etc.).', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_company_balance_sheet', { code }), + }), + + twstockGetCompanyIncomeStatement: tool({ + description: 'Get comprehensive income statement for a TWSE-listed company. Auto-detects industry format.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_company_income_statement', { code }), + }), + + twstockGetCompanyMonthlyRevenue: tool({ + description: 'Get monthly revenue information for a TWSE-listed company.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_company_monthly_revenue', { code }), + }), + + twstockGetCompanyDividend: tool({ + description: 'Get dividend distribution history for a TWSE-listed company.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_company_dividend', { code }), + }), + + twstockGetCompanyMajorNews: tool({ + description: 'Get daily major announcements from TWSE-listed companies. Material information disclosures that may impact stock prices.', + inputSchema: z.object({ + code: z.string().optional().describe('Stock code to filter. Omit to get all major announcements.'), + }), + execute: async ({ code }) => call('get_company_major_news', code ? { code } : {}), + }), + + // ==================== ESG / Governance ==================== + + twstockGetCompanyGovernanceInfo: tool({ + description: 'Get corporate governance information for a TWSE-listed company.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_company_governance_info', { code }), + }), + + twstockGetCompanyClimateManagement: tool({ + description: 'Get climate-related management information for a TWSE-listed company.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_company_climate_management', { code }), + }), + + twstockGetCompanyRiskManagement: tool({ + description: 'Get risk management policy information for a TWSE-listed company.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_company_risk_management', { code }), + }), + + twstockGetCompanyInfoSecurity: tool({ + description: 'Get information security data for a TWSE-listed company.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_company_info_security', { code }), + }), + + twstockGetCompanySupplyChainManagement: tool({ + description: 'Get supply chain management information for a TWSE-listed company.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_company_supply_chain_management', { code }), + }), + + // ==================== Trading Data ==================== + + twstockGetStockRealtimeQuote: tool({ + description: `Get real-time intraday quote for a TWSE-listed stock. + +Returns current price, OHLC, volume, best 5 bid/ask, and price change. +Updated every few seconds during market hours (09:00-13:30 TST). +Use this instead of twstockGetStockDailyTrading when you need today's data before market close.`, + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_stock_realtime_quote', { code }), + }), + + twstockGetStockDailyTrading: tool({ + description: 'Get daily trading information (open, high, low, close, volume) for a TWSE-listed stock. Note: data updates after market close; for intraday data use twstockGetStockRealtimeQuote.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_stock_daily_trading', { code }), + }), + + twstockGetStockMonthlyTrading: tool({ + description: 'Get monthly trading information for a TWSE-listed stock.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_stock_monthly_trading', { code }), + }), + + twstockGetStockYearlyTrading: tool({ + description: 'Get yearly trading information for a TWSE-listed stock.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_stock_yearly_trading', { code }), + }), + + twstockGetStockMonthlyAverage: tool({ + description: 'Get daily closing price and monthly average price for a TWSE-listed stock.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_stock_monthly_average', { code }), + }), + + twstockGetStockKlineData: tool({ + description: `Get historical daily OHLCV data for K-line (candlestick) chart rendering. + +Returns structured JSON with date, open, high, low, close, volume for each trading day. +IMPORTANT: When presenting this data, wrap the entire JSON output in a \`\`\`kline code block +so the frontend renders an interactive candlestick chart automatically.`, + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + months: z.number().int().optional().describe('Months of history (1-12, default: 3)'), + }), + execute: async ({ code, months }) => { + const args: Record = { code } + if (months) args.months = months + return call('get_stock_kline_data', args) + }, + }), + + twstockGetStockValuationRatios: tool({ + description: 'Get P/E ratio, dividend yield, and P/B ratio for a TWSE-listed stock.', + inputSchema: z.object({ + code: z.string().describe('Stock code, e.g. "2330"'), + }), + execute: async ({ code }) => call('get_stock_valuation_ratios', { code }), + }), + + twstockGetRealTimeTradingStats: tool({ + description: 'Get real-time 5-second trading statistics including order volumes and transaction counts for the TWSE market.', + inputSchema: z.object({}), + execute: async () => call('get_real_time_trading_stats'), + }), + + // ==================== Market Indices ==================== + + twstockGetMarketIndexInfo: tool({ + description: `Get daily market closing information and index statistics for TWSE. + +Categories: "major" (main indices), "sector" (industry), "esg", "leverage", "return" (total return), "thematic", "dividend", "all".`, + inputSchema: z.object({ + category: z.string().optional().describe('Index category (default: "major")'), + count: z.number().int().optional().describe('Max indices to return (default: 20, max: 50)'), + format: z.string().optional().describe('Output format: "detailed", "summary", "simple" (default: "detailed")'), + }), + execute: async ({ category, count, format }) => { + const args: Record = {} + if (category) args.category = category + if (count) args.count = count + if (format) args.format = format + return call('get_market_index_info', args) + }, + }), + + twstockGetMarketHistoricalIndex: tool({ + description: 'Get historical TAIEX (Taiwan Capitalization Weighted Stock Index) data for long-term trend analysis.', + inputSchema: z.object({}), + execute: async () => call('get_market_historical_index'), + }), + + // ==================== Foreign Investment ==================== + + twstockGetTopForeignHoldings: tool({ + description: 'Get top 20 companies by foreign and mainland China investment holdings ranking.', + inputSchema: z.object({}), + execute: async () => call('get_top_foreign_holdings'), + }), + + twstockGetForeignInvestmentByIndustry: tool({ + description: 'Get foreign and mainland China investment holding ratios by industry category.', + inputSchema: z.object({}), + execute: async () => call('get_foreign_investment_by_industry'), + }), + + // ==================== Margin Trading ==================== + + twstockGetMarginTradingInfo: tool({ + description: 'Get margin trading and short selling balance information for the TWSE market.', + inputSchema: z.object({}), + execute: async () => call('get_margin_trading_info'), + }), + + // ==================== Dividends / Corporate Actions ==================== + + twstockGetDividendRightsSchedule: tool({ + description: 'Get ex-dividend and ex-rights schedule for TWSE-listed stocks, including dates, stock/cash dividends, and rights offerings.', + inputSchema: z.object({ + code: z.string().optional().describe('Stock code to filter. Omit to get all upcoming schedules.'), + }), + execute: async ({ code }) => call('get_dividend_rights_schedule', code ? { code } : {}), + }), + + twstockGetEtfRegularInvestmentRanking: tool({ + description: 'Get top 10 securities and ETFs by number of regular investment (定期定額) accounts.', + inputSchema: z.object({}), + execute: async () => call('get_etf_regular_investment_ranking'), + }), + + // ==================== TWSE News & Events ==================== + + twstockGetTwseNews: tool({ + description: 'Get latest news from Taiwan Stock Exchange with optional date filtering.', + inputSchema: z.object({ + start_date: z.string().optional().describe('Start date (YYYYMMDD). Defaults to current month start.'), + end_date: z.string().optional().describe('End date (YYYYMMDD). Defaults to current month end.'), + }), + execute: async ({ start_date, end_date }) => { + const args: Record = {} + if (start_date) args.start_date = start_date + if (end_date) args.end_date = end_date + return call('get_twse_news', args) + }, + }), + + twstockGetTwseEvents: tool({ + description: 'Get Taiwan Stock Exchange event announcements, seminars, and activities.', + inputSchema: z.object({ + top: z.number().int().optional().describe('Number of events to return (default: 10, 0 for all)'), + }), + execute: async ({ top }) => call('get_twse_events', top ? { top } : {}), + }), + + // ==================== Warrants ==================== + + twstockGetWarrantBasicInfo: tool({ + description: 'Get basic information of TWSE-listed warrants (type, exercise period, underlying asset, etc.).', + inputSchema: z.object({ + code: z.string().optional().describe('Warrant code to filter. Omit for all warrants.'), + }), + execute: async ({ code }) => call('get_warrant_basic_info', code ? { code } : {}), + }), + + twstockGetWarrantDailyTrading: tool({ + description: 'Get daily trading data (volume, value) for TWSE-listed warrants.', + inputSchema: z.object({ + code: z.string().optional().describe('Warrant code to filter. Omit for all warrants.'), + }), + execute: async ({ code }) => call('get_warrant_daily_trading', code ? { code } : {}), + }), + + twstockGetWarrantTraderCount: tool({ + description: 'Get daily number of individual warrant traders on the TWSE.', + inputSchema: z.object({}), + execute: async () => call('get_warrant_trader_count'), + }), + } +} diff --git a/ui/package.json b/ui/package.json index ab04de7e..95ebaf2d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,6 +10,7 @@ "dependencies": { "dompurify": "^3.3.1", "highlight.js": "^11.11.1", + "lightweight-charts": "^5.1.0", "marked": "^15.0.12", "marked-highlight": "^2.2.1", "react": "^19.1.0", diff --git a/ui/src/components/ChatMessage.tsx b/ui/src/components/ChatMessage.tsx index 55d22c0a..e07dacb7 100644 --- a/ui/src/components/ChatMessage.tsx +++ b/ui/src/components/ChatMessage.tsx @@ -5,6 +5,8 @@ import { markedHighlight } from 'marked-highlight' import hljs from 'highlight.js' import DOMPurify from 'dompurify' import 'highlight.js/styles/github-dark.min.css' +import { KlineChart } from './KlineChart' +import type { KlineDataPoint } from './KlineChart' const marked = new Marked( markedHighlight({ @@ -42,6 +44,55 @@ function AliceAvatar() { ) } +// ==================== Kline block extraction ==================== + +interface ContentSegment { + type: 'text' | 'kline' + content: string // markdown text or raw JSON + kline?: { symbol?: string; name?: string; data: KlineDataPoint[] } +} + +const KLINE_FENCE = /```kline\s*\n([\s\S]*?)```/g + +function extractKlineSegments(text: string): ContentSegment[] { + const segments: ContentSegment[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = KLINE_FENCE.exec(text)) !== null) { + if (match.index > lastIndex) { + segments.push({ type: 'text', content: text.slice(lastIndex, match.index) }) + } + try { + const parsed = JSON.parse(match[1]) + segments.push({ + type: 'kline', + content: match[1], + kline: { + symbol: parsed.symbol, + name: parsed.name, + data: parsed.data, + }, + }) + } catch { + // Malformed JSON — render as regular code block + segments.push({ type: 'text', content: match[0] }) + } + lastIndex = match.index + match[0].length + } + + if (lastIndex < text.length) { + segments.push({ type: 'text', content: text.slice(lastIndex) }) + } + + // Reset regex lastIndex for next call + KLINE_FENCE.lastIndex = 0 + + return segments.length > 0 ? segments : [{ type: 'text', content: text }] +} + +// ==================== Code block wrappers ==================== + function addCodeBlockWrappers(html: string): string { return html.replace( /
([\s\S]*?)<\/code><\/pre>/g,
@@ -57,12 +108,20 @@ function addCodeBlockWrappers(html: string): string {
 export function ChatMessage({ role, text, timestamp, isGrouped, media }: ChatMessageProps) {
   const contentRef = useRef(null)
 
-  const html = useMemo(() => {
+  const segments = useMemo(() => {
     if (role === 'user') return null
-    const raw = DOMPurify.sanitize(marked.parse(text) as string)
-    return addCodeBlockWrappers(raw)
+    return extractKlineSegments(text)
   }, [role, text])
 
+  const renderSegment = useCallback((seg: ContentSegment, i: number) => {
+    if (seg.type === 'kline' && seg.kline) {
+      return 
+    }
+    const raw = DOMPurify.sanitize(marked.parse(seg.content) as string)
+    const html = addCodeBlockWrappers(raw)
+    return 
+ }, []) + const handleCopyClick = useCallback((e: MouseEvent) => { const btn = (e.target as HTMLElement).closest('.code-copy-btn') as HTMLButtonElement | null if (!btn) return @@ -98,7 +157,7 @@ export function ChatMessage({ role, text, timestamp, isGrouped, media }: ChatMes Notification
-
+ {segments?.map(renderSegment)} {media?.map((m, i) => ( ))} @@ -133,7 +192,7 @@ export function ChatMessage({ role, text, timestamp, isGrouped, media }: ChatMes
)}
-
+ {segments?.map(renderSegment)} {media?.map((m, i) => ( ))} diff --git a/ui/src/components/KlineChart.tsx b/ui/src/components/KlineChart.tsx new file mode 100644 index 00000000..83d6e45f --- /dev/null +++ b/ui/src/components/KlineChart.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef } from 'react' +import { createChart, CandlestickSeries, HistogramSeries, LineSeries } from 'lightweight-charts' +import type { CandlestickData, HistogramData, LineData, Time } from 'lightweight-charts' + +export interface KlineDataPoint { + /** Date string: YYYY-MM-DD or YYYYMMDD */ + d: string + o: number + h: number + l: number + c: number + v?: number +} + +interface KlineChartProps { + symbol?: string + name?: string + data: KlineDataPoint[] +} + +function parseTime(d: string): Time { + // ISO datetime (intraday): "2026-04-08T09:00:00.000+08:00" → Unix timestamp (seconds) + // lightweight-charts displays in UTC, so we shift by local timezone offset to show correct local time + if (d.includes('T')) { + const date = new Date(d) + const utcSeconds = Math.floor(date.getTime() / 1000) + const offsetSeconds = -date.getTimezoneOffset() * 60 // positive for UTC+8 + return (utcSeconds + offsetSeconds) as unknown as Time + } + // YYYYMMDD → YYYY-MM-DD + if (/^\d{8}$/.test(d)) return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` as unknown as Time + // Already YYYY-MM-DD + return d as unknown as Time +} + +export function KlineChart({ symbol, name, data }: KlineChartProps) { + const containerRef = useRef(null) + + useEffect(() => { + const el = containerRef.current + if (!el || data.length === 0) return + + const isIntraday = data[0].d.includes('T') + + const chart = createChart(el, { + width: el.clientWidth, + height: 360, + layout: { + background: { color: 'transparent' }, + textColor: '#8b949e', + fontSize: 11, + }, + grid: { + vertLines: { color: 'rgba(139,148,158,0.08)' }, + horzLines: { color: 'rgba(139,148,158,0.08)' }, + }, + crosshair: { mode: 0 }, + rightPriceScale: { borderColor: 'rgba(139,148,158,0.2)' }, + localization: { locale: 'zh-TW' }, + timeScale: { + borderColor: 'rgba(139,148,158,0.2)', + timeVisible: isIntraday, + secondsVisible: false, + shiftVisibleRangeOnNewBar: true, + }, + }) + + // Candlestick series + const candleSeries = chart.addSeries(CandlestickSeries, { + upColor: '#26a69a', + downColor: '#ef5350', + borderUpColor: '#26a69a', + borderDownColor: '#ef5350', + wickUpColor: '#26a69a', + wickDownColor: '#ef5350', + }) + + const candleData: CandlestickData[] = data.map((p) => ({ + time: parseTime(p.d), + open: p.o, + high: p.h, + low: p.l, + close: p.c, + })) + candleSeries.setData(candleData) + + // Moving averages + const MA_CONFIG = [ + { period: 5, color: '#f6c244', label: 'MA5' }, + { period: 20, color: '#2196f3', label: 'MA20' }, + { period: 60, color: '#ab47bc', label: 'MA60' }, + ] + + for (const ma of MA_CONFIG) { + if (data.length < ma.period) continue + const maData: LineData[] = [] + for (let i = ma.period - 1; i < data.length; i++) { + let sum = 0 + for (let j = i - ma.period + 1; j <= i; j++) sum += data[j].c + maData.push({ time: parseTime(data[i].d), value: sum / ma.period }) + } + const maSeries = chart.addSeries(LineSeries, { + color: ma.color, + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + crosshairMarkerVisible: false, + }) + maSeries.setData(maData) + } + + // Volume histogram + const hasVolume = data.some((p) => p.v != null && p.v > 0) + if (hasVolume) { + const volumeSeries = chart.addSeries(HistogramSeries, { + priceFormat: { type: 'volume' }, + priceScaleId: 'volume', + }) + chart.priceScale('volume').applyOptions({ + scaleMargins: { top: 0.8, bottom: 0 }, + }) + + const volumeData: HistogramData[] = data.map((p) => ({ + time: parseTime(p.d), + value: p.v ?? 0, + color: p.c >= p.o ? 'rgba(38,166,154,0.3)' : 'rgba(239,83,80,0.3)', + })) + volumeSeries.setData(volumeData) + } + + chart.timeScale().fitContent() + + const ro = new ResizeObserver(() => { + chart.applyOptions({ width: el.clientWidth }) + }) + ro.observe(el) + + return () => { + ro.disconnect() + chart.remove() + } + }, [data]) + + const title = [name, symbol ? `(${symbol})` : ''].filter(Boolean).join(' ') + + return ( +
+ {title && ( +
+ {title} K-Line +
+ )} +
+
+ ) +}