Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/ai-providers/agent-sdk/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export async function askAgentSdk(
const isOAuthMode = loginMethod === 'claudeai'

const env: Record<string, string | undefined> = { ...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
Expand Down
32 changes: 32 additions & 0 deletions src/domain/fugle/config.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fugleConfigSchema>

export async function readFugleConfig(): Promise<FugleConfig> {
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({})
}
}
68 changes: 68 additions & 0 deletions src/domain/twstock/client.ts
Original file line number Diff line number Diff line change
@@ -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<Client> | null = null

constructor(private mcpUrl: string) {}

private async ensureConnected(): Promise<Client> {
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<string, unknown> = {}): Promise<unknown> {
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<void> {
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')
}
35 changes: 35 additions & 0 deletions src/domain/twstock/config.ts
Original file line number Diff line number Diff line change
@@ -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<typeof twstockConfigSchema>

/** Read twstock config from disk. Seeds defaults if file is missing. */
export async function readTwstockConfig(): Promise<TwstockConfig> {
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({})
}
}
23 changes: 23 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ====================

Expand Down Expand Up @@ -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 ====================
Expand Down Expand Up @@ -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)
Expand Down
135 changes: 135 additions & 0 deletions src/tool/fugle.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => {
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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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.
}
}
Loading