first commit

This commit is contained in:
Shotaro Nakamura
2026-06-18 22:59:53 +09:00
commit 20434dcd11
88 changed files with 13950 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.CSBIE_DATABASE_PATH ?? './data/csbie.sqlite',
},
})
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@repo/csbie-server",
"private": true,
"type": "module",
"exports": {
"./app": "./src/app.ts",
"./config": "./src/config.ts",
"./db": "./src/db/index.ts"
},
"scripts": {
"dev": "bun --env-file=../../.env --watch src/index.ts",
"clean": "rm -rf dist drizzle",
"typecheck": "tsc",
"db:push": "bun --env-file=../../.env drizzle-kit push"
},
"dependencies": {
"@hono/mcp": "^0.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/keyring": "^1.2.0",
"@repo/sbi-client": "workspace:*",
"@simplewebauthn/server": "^13.1.2",
"drizzle-orm": "^0.44.2",
"hono": "^4.8.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/bun": "latest",
"drizzle-kit": "^0.31.1",
"typescript": "^5"
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { secureHeaders } from 'hono/secure-headers'
import { mcpAuthRouter } from '@hono/mcp'
import type { ServerConfig } from './config'
import type { AppBindings } from './context'
import type { Db } from './db'
import { createOAuthServerProvider } from './security/oauth-provider'
import { authenticateRequest } from './security/http-auth'
import { createAdminRoutes } from './routes/admin'
import { createAuthRoutes } from './routes/auth'
import { createMcpRoutes } from './routes/mcp'
import { createOAuthRoutes } from './routes/oauth'
import { createRpcWebSocket } from './rpc/ws'
export const createServerApp = (db: Db, config: ServerConfig) => {
const app = new Hono<AppBindings>()
const rpcWebSocket = createRpcWebSocket(db, config)
const oauthProvider = createOAuthServerProvider(db, config)
app.use('*', async (c, next) => {
const auth = await authenticateRequest(db, config, c.req.raw)
c.set('db', db)
c.set('config', config)
c.set('auth', auth)
c.set('authenticated', auth.authenticated)
await next()
})
app.use(
'*',
secureHeaders({
crossOriginEmbedderPolicy: false,
}),
)
app.use(
'*',
cors({
origin: config.corsOrigin,
credentials: true,
}),
)
app.get('/health', (c) => c.json({ ok: true }))
app.route(
'/',
mcpAuthRouter({
issuerUrl: new URL(config.origin),
baseUrl: new URL(config.origin),
resourceServerUrl: new URL('/api/mcp', config.origin),
resourceName: 'CSBIE MCP',
scopesSupported: ['mcp'],
provider: oauthProvider,
authorizationOptions: { rateLimit: false },
tokenOptions: { rateLimit: false },
clientRegistrationOptions: { rateLimit: false },
revocationOptions: { rateLimit: false },
}),
)
app.get('/ws', rpcWebSocket.upgradeWebSocket)
app.route('/auth', createAuthRoutes())
app.route('/admin', createAdminRoutes())
app.route('/oauth', createOAuthRoutes())
app.route('/mcp', createMcpRoutes())
return { app, websocket: rpcWebSocket.websocket }
}
+42
View File
@@ -0,0 +1,42 @@
import { mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
export type ServerConfig = {
port: number
databasePath: string
corsOrigin: string
sessionCookieName: string
rpName: string
rpId: string
origin: string
authBaseUrl?: string
mtsBaseUrl?: string
izanagiBaseUrl?: string
}
const optionalUrl = (value: string | undefined) => {
if (!value) return undefined
return new URL(value).toString()
}
export const loadConfig = (): ServerConfig => {
const port = Number(process.env.PORT ?? process.env.CSBIE_SERVER_PORT ?? 8787)
const databasePath = resolve(process.env.CSBIE_DATABASE_PATH ?? './data/csbie.sqlite')
mkdirSync(dirname(databasePath), { recursive: true })
const origin = process.env.CSBIE_ORIGIN ?? `http://localhost:${port}`
const rpId = process.env.CSBIE_RP_ID ?? new URL(origin).hostname
return {
port,
databasePath,
corsOrigin: process.env.CSBIE_CORS_ORIGIN ?? origin,
sessionCookieName: process.env.CSBIE_SESSION_COOKIE ?? 'csbie_session',
rpName: process.env.CSBIE_RP_NAME ?? 'CSBIE',
rpId,
origin,
authBaseUrl: optionalUrl(process.env.SBI_AUTH_BASE_URL),
mtsBaseUrl: optionalUrl(process.env.SBI_MTS_BASE_URL),
izanagiBaseUrl: optionalUrl(process.env.SBI_IZANAGI_BASE_URL),
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { ServerConfig } from './config'
import type { Db } from './db'
export type AppBindings = {
Variables: {
db: Db
config: ServerConfig
authenticated: boolean
auth: AuthContext
}
}
export type AuthContext =
| {
type: 'none'
authenticated: false
}
| {
type: 'session'
authenticated: true
sessionId: string
}
| {
type: 'apiKey'
authenticated: true
apiKeyId: string
}
+152
View File
@@ -0,0 +1,152 @@
import { Database } from 'bun:sqlite'
import { drizzle } from 'drizzle-orm/bun-sqlite'
import * as schema from './schema'
export type Db = ReturnType<typeof createDb>
export const createDb = (path: string) => {
const sqlite = new Database(path, { create: true, strict: true })
sqlite.run('PRAGMA journal_mode = WAL')
sqlite.run('PRAGMA foreign_keys = ON')
sqlite.run(`
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`)
sqlite.run(`
CREATE TABLE IF NOT EXISTS user_passkeys (
id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
sqlite.run(`
CREATE TABLE IF NOT EXISTS passkey_challenges (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
challenge TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
`)
sqlite.run(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
`)
sqlite.run(`
CREATE TABLE IF NOT EXISTS sbi_passkeys (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
keyring_account TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
sqlite.run(`
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
max_trades_per_hour INTEGER,
max_trades_per_6_hours INTEGER,
max_trades_per_day INTEGER,
max_order_price_jpy INTEGER,
max_order_amount_jpy INTEGER,
allowed_methods TEXT,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked_at INTEGER
)
`)
const apiKeyColumns = sqlite
.query<{ name: string }, []>('PRAGMA table_info(api_keys)')
.all()
.map((column) => column.name)
for (const [name, type] of [
['max_trades_per_hour', 'INTEGER'],
['max_trades_per_6_hours', 'INTEGER'],
['max_trades_per_day', 'INTEGER'],
['max_order_price_jpy', 'INTEGER'],
['max_order_amount_jpy', 'INTEGER'],
['allowed_methods', 'TEXT'],
] as const) {
if (!apiKeyColumns.includes(name)) sqlite.run(`ALTER TABLE api_keys ADD COLUMN ${name} ${type}`)
}
sqlite.run(`
CREATE TABLE IF NOT EXISTS api_key_trade_usage (
api_key_id TEXT NOT NULL,
window TEXT NOT NULL,
hour_bucket TEXT NOT NULL,
trade_count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (api_key_id, window, hour_bucket)
)
`)
const tradeUsageColumns = sqlite
.query<{ name: string }, []>('PRAGMA table_info(api_key_trade_usage)')
.all()
.map((column) => column.name)
if (!tradeUsageColumns.includes('window')) {
sqlite.run('DROP TABLE api_key_trade_usage')
sqlite.run(`
CREATE TABLE api_key_trade_usage (
api_key_id TEXT NOT NULL,
window TEXT NOT NULL,
hour_bucket TEXT NOT NULL,
trade_count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (api_key_id, window, hour_bucket)
)
`)
}
sqlite.run(`
CREATE TABLE IF NOT EXISTS oauth_clients (
id TEXT PRIMARY KEY,
client TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`)
sqlite.run(`
CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_challenge TEXT NOT NULL,
scopes TEXT NOT NULL,
resource TEXT,
api_key_settings TEXT,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
`)
const oauthCodeColumns = sqlite
.query<{ name: string }, []>('PRAGMA table_info(oauth_authorization_codes)')
.all()
.map((column) => column.name)
if (!oauthCodeColumns.includes('api_key_settings')) {
sqlite.run('ALTER TABLE oauth_authorization_codes ADD COLUMN api_key_settings TEXT')
}
sqlite.run(`
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
token_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
api_key_id TEXT NOT NULL,
scopes TEXT NOT NULL,
resource TEXT,
expires_at INTEGER NOT NULL,
revoked_at INTEGER,
created_at INTEGER NOT NULL
)
`)
return drizzle(sqlite, { schema })
}
+102
View File
@@ -0,0 +1,102 @@
import { integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
export const appState = sqliteTable('app_state', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
})
export const userPasskeys = sqliteTable('user_passkeys', {
id: text('id').primaryKey(),
credentialId: text('credential_id').notNull(),
publicKey: text('public_key').notNull(),
counter: integer('counter').notNull().default(0),
transports: text('transports', { mode: 'json' }).$type<string[] | undefined>(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
})
export const passkeyChallenges = sqliteTable('passkey_challenges', {
id: text('id').primaryKey(),
kind: text('kind', { enum: ['registration', 'authentication'] }).notNull(),
challenge: text('challenge').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})
export const sbiPasskeys = sqliteTable('sbi_passkeys', {
id: text('id').primaryKey(),
label: text('label').notNull(),
keyringAccount: text('keyring_account').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
})
export const apiKeys = sqliteTable(
'api_keys',
{
id: text('id').primaryKey(),
label: text('label').notNull(),
tokenHash: text('token_hash').notNull(),
maxTradesPerHour: integer('max_trades_per_hour'),
maxTradesPer6Hours: integer('max_trades_per_6_hours'),
maxTradesPerDay: integer('max_trades_per_day'),
maxOrderPriceJpy: integer('max_order_price_jpy'),
maxOrderAmountJpy: integer('max_order_amount_jpy'),
allowedMethods: text('allowed_methods', { mode: 'json' }).$type<string[] | null>(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }),
revokedAt: integer('revoked_at', { mode: 'timestamp_ms' }),
},
(table) => [uniqueIndex('api_keys_token_hash_unique').on(table.tokenHash)],
)
export const apiKeyTradeUsage = sqliteTable(
'api_key_trade_usage',
{
apiKeyId: text('api_key_id').notNull(),
window: text('window', { enum: ['1h', '3h', '1d'] }).notNull(),
hourBucket: text('hour_bucket').notNull(),
tradeCount: integer('trade_count').notNull().default(0),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
(table) => [primaryKey({ columns: [table.apiKeyId, table.window, table.hourBucket] })],
)
export const oauthClients = sqliteTable('oauth_clients', {
id: text('id').primaryKey(),
client: text('client', { mode: 'json' }).$type<Record<string, unknown>>().notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})
export const oauthAuthorizationCodes = sqliteTable('oauth_authorization_codes', {
code: text('code').primaryKey(),
clientId: text('client_id').notNull(),
redirectUri: text('redirect_uri').notNull(),
codeChallenge: text('code_challenge').notNull(),
scopes: text('scopes', { mode: 'json' }).$type<string[]>().notNull(),
resource: text('resource'),
apiKeySettings: text('api_key_settings', { mode: 'json' }).$type<Record<
string,
unknown
> | null>(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})
export const oauthRefreshTokens = sqliteTable('oauth_refresh_tokens', {
tokenHash: text('token_hash').primaryKey(),
clientId: text('client_id').notNull(),
apiKeyId: text('api_key_id').notNull(),
scopes: text('scopes', { mode: 'json' }).$type<string[]>().notNull(),
resource: text('resource'),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
revokedAt: integer('revoked_at', { mode: 'timestamp_ms' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})
+17
View File
@@ -0,0 +1,17 @@
import { loadConfig } from './config'
import { createDb } from './db'
import { createServerApp } from './app'
const config = loadConfig()
const db = createDb(config.databasePath)
const { app, websocket } = createServerApp(db, config)
const server = Bun.serve({
port: config.port,
fetch(request, server) {
return app.fetch(request, { server })
},
websocket,
})
console.log(`csbie-server listening on http://localhost:${server.port}`)
+108
View File
@@ -0,0 +1,108 @@
import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import type { MiddlewareHandler } from 'hono'
import type { PlaintextStoredWebAuthnCredential } from '@repo/sbi-client'
import type { AppBindings } from '../context'
import { sbiPasskeys } from '../db/schema'
import {
createApiKey,
listApiKeys,
revokeApiKey,
updateApiKeySettings,
type ApiKeySettings,
} from '../security/api-keys'
import { randomId } from '../security/crypto'
import { deleteSecret, saveSecret } from '../security/keyring'
export type StoredSbiPasskeySecret = {
credential: PlaintextStoredWebAuthnCredential
tradePassword?: string
deviceId?: string
}
const requireAuth: MiddlewareHandler<AppBindings> = async (c, next) => {
if (!c.get('authenticated')) return c.json({ error: 'unauthorized' }, 401)
await next()
}
export const createAdminRoutes = () => {
const app = new Hono<AppBindings>()
app.use('*', requireAuth)
app.get('/api-keys', async (c) => c.json({ apiKeys: await listApiKeys(c.get('db')) }))
app.post('/api-keys', async (c) => {
const { label, settings } = await c.req.json<{ label?: string; settings?: ApiKeySettings }>()
if (!label?.trim()) return c.json({ error: 'label is required' }, 400)
const key = await createApiKey(c.get('db'), label.trim(), settings)
return c.json({ apiKey: key }, 201)
})
app.patch('/api-keys/:id/settings', async (c) => {
const body = await c.req.json<ApiKeySettings>()
await updateApiKeySettings(c.get('db'), c.req.param('id'), body)
return c.json({ ok: true })
})
app.delete('/api-keys/:id', async (c) => {
await revokeApiKey(c.get('db'), c.req.param('id'))
return c.json({ ok: true })
})
app.get('/sbi-passkeys', async (c) => {
const rows = await c.get('db').select().from(sbiPasskeys).orderBy(sbiPasskeys.createdAt)
return c.json({
passkeys: rows.map(({ keyringAccount: _keyringAccount, ...row }) => ({
...row,
keyringAccount: undefined,
})),
})
})
app.post('/sbi-passkeys', async (c) => {
const body = await c.req.json<{
label?: string
credential?: PlaintextStoredWebAuthnCredential
tradePassword?: string
deviceId?: string
}>()
if (!body.label?.trim() || !body.credential) {
return c.json({ error: 'label and credential are required' }, 400)
}
const now = new Date()
const id = randomId('sbi')
const keyringAccount = `sbi-passkey:${id}`
await saveSecret(keyringAccount, {
credential: body.credential,
tradePassword: body.tradePassword,
deviceId: body.deviceId,
} satisfies StoredSbiPasskeySecret)
await c.get('db').insert(sbiPasskeys).values({
id,
label: body.label.trim(),
keyringAccount,
createdAt: now,
updatedAt: now,
})
return c.json(
{ passkey: { id, label: body.label.trim(), createdAt: now, updatedAt: now } },
201,
)
})
app.delete('/sbi-passkeys/:id', async (c) => {
const db = c.get('db')
const [row] = await db
.select()
.from(sbiPasskeys)
.where(eq(sbiPasskeys.id, c.req.param('id')))
if (!row) return c.json({ error: 'not found' }, 404)
await deleteSecret(row.keyringAccount)
await db.delete(sbiPasskeys).where(eq(sbiPasskeys.id, row.id))
return c.json({ ok: true })
})
return app
}
+218
View File
@@ -0,0 +1,218 @@
import {
type AuthenticatorTransportFuture,
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server'
import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import type { AppBindings } from '../context'
import { appState, passkeyChallenges, userPasskeys } from '../db/schema'
import { randomId } from '../security/crypto'
import {
clearSessionCookie,
createSession,
setSessionCookie,
verifySessionCookie,
} from '../security/sessions'
import { verifySetupPassword } from '../security/setup'
type PasskeyRow = typeof userPasskeys.$inferSelect
const getConfigured = async (db: AppBindings['Variables']['db']) => {
const [row] = await db.select().from(appState).where(eq(appState.key, 'configured')).limit(1)
return row?.value === 'true'
}
const saveChallenge = async (
db: AppBindings['Variables']['db'],
kind: 'registration' | 'authentication',
challenge: string,
) => {
const now = new Date()
const id = randomId('chal')
await db.insert(passkeyChallenges).values({
id,
kind,
challenge,
createdAt: now,
expiresAt: new Date(now.getTime() + 5 * 60 * 1000),
})
return id
}
const consumeChallenge = async (
db: AppBindings['Variables']['db'],
id: string,
kind: 'registration' | 'authentication',
) => {
const [row] = await db
.select()
.from(passkeyChallenges)
.where(eq(passkeyChallenges.id, id))
.limit(1)
if (!row || row.kind !== kind || row.expiresAt < new Date()) throw new Error('challenge expired')
await db.delete(passkeyChallenges).where(eq(passkeyChallenges.id, id))
return row.challenge
}
const toCredentialDescriptor = (row: PasskeyRow) => ({
id: row.credentialId,
transports: row.transports?.filter(isAuthenticatorTransport),
})
const isAuthenticatorTransport = (value: string): value is AuthenticatorTransportFuture =>
['ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', 'usb'].includes(value)
const passkeyForCredential = async (db: AppBindings['Variables']['db'], credentialId: string) => {
const [row] = await db
.select()
.from(userPasskeys)
.where(eq(userPasskeys.credentialId, credentialId))
.limit(1)
return row
}
export const createAuthRoutes = () => {
const app = new Hono<AppBindings>()
app.get('/status', async (c) => {
const db = c.get('db')
return c.json({
configured: await getConfigured(db),
authenticated: await verifySessionCookie(c, db, c.get('config')),
})
})
app.post('/setup/options', async (c) => {
const db = c.get('db')
if (await getConfigured(db)) return c.json({ error: 'already configured' }, 409)
const { password } = await c.req.json<{ password?: string }>()
if (!password || !verifySetupPassword(password)) {
return c.json({ error: 'invalid setup password' }, 401)
}
const config = c.get('config')
const options = await generateRegistrationOptions({
rpName: config.rpName,
rpID: config.rpId,
userName: 'owner',
userDisplayName: 'Owner',
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'required',
},
})
const challengeId = await saveChallenge(db, 'registration', options.challenge)
return c.json({ options, challengeId })
})
app.post('/setup/verify', async (c) => {
const db = c.get('db')
if (await getConfigured(db)) return c.json({ error: 'already configured' }, 409)
const { challengeId, response } = await c.req.json<{
challengeId?: string
response?: unknown
}>()
if (!challengeId || !response) return c.json({ error: 'missing registration response' }, 400)
const expectedChallenge = await consumeChallenge(db, challengeId, 'registration')
const config = c.get('config')
const verification = await verifyRegistrationResponse({
response: response as never,
expectedChallenge,
expectedOrigin: config.origin,
expectedRPID: config.rpId,
requireUserVerification: true,
})
if (!verification.verified || !verification.registrationInfo) {
return c.json({ error: 'registration failed' }, 400)
}
const now = new Date()
const credential = verification.registrationInfo.credential
await db.insert(userPasskeys).values({
id: randomId('upk'),
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey).toString('base64url'),
counter: credential.counter,
transports: (response as { response?: { transports?: string[] } }).response?.transports,
createdAt: now,
updatedAt: now,
})
await db
.insert(appState)
.values({ key: 'configured', value: 'true', updatedAt: now })
.onConflictDoUpdate({ target: appState.key, set: { value: 'true', updatedAt: now } })
const session = await createSession(db)
setSessionCookie(c, config, session.id, session.expiresAt)
return c.json({ ok: true })
})
app.post('/login/options', async (c) => {
const db = c.get('db')
const rows = await db.select().from(userPasskeys)
if (rows.length === 0) return c.json({ error: 'not configured' }, 409)
const options = await generateAuthenticationOptions({
rpID: c.get('config').rpId,
allowCredentials: rows.map(toCredentialDescriptor),
userVerification: 'required',
})
const challengeId = await saveChallenge(db, 'authentication', options.challenge)
return c.json({ options, challengeId })
})
app.post('/login/verify', async (c) => {
const db = c.get('db')
const { challengeId, response } = await c.req.json<{
challengeId?: string
response?: { id?: string }
}>()
if (!challengeId || !response?.id)
return c.json({ error: 'missing authentication response' }, 400)
const passkey = await passkeyForCredential(db, response.id)
if (!passkey) return c.json({ error: 'unknown passkey' }, 401)
const expectedChallenge = await consumeChallenge(db, challengeId, 'authentication')
const config = c.get('config')
const verification = await verifyAuthenticationResponse({
response: response as never,
expectedChallenge,
expectedOrigin: config.origin,
expectedRPID: config.rpId,
credential: {
id: passkey.credentialId,
publicKey: Buffer.from(passkey.publicKey, 'base64url'),
counter: passkey.counter,
transports: passkey.transports?.filter(isAuthenticatorTransport),
},
requireUserVerification: true,
})
if (!verification.verified) return c.json({ error: 'authentication failed' }, 401)
await db
.update(userPasskeys)
.set({ counter: verification.authenticationInfo.newCounter, updatedAt: new Date() })
.where(eq(userPasskeys.id, passkey.id))
const session = await createSession(db)
setSessionCookie(c, config, session.id, session.expiresAt)
return c.json({ ok: true })
})
app.post('/logout', async (c) => {
await clearSessionCookie(c, c.get('db'), c.get('config'))
return c.json({ ok: true })
})
return app
}
+604
View File
@@ -0,0 +1,604 @@
import { Hono } from 'hono'
import type { Context } from 'hono'
import { randomUUID } from 'node:crypto'
import { StreamableHTTPTransport } from '@hono/mcp'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { eq } from 'drizzle-orm'
import * as z from 'zod/v4'
import type { AppBindings, AuthContext } from '../context'
import { sbiPasskeys } from '../db/schema'
import {
invokeSbiMethod,
isCashOrderMethod,
isTradingMethod,
RPC_METHODS,
type RpcMethod,
} from '../rpc/methods'
import { connectSbi } from '../rpc/sbi-session'
import type { StoredSbiPasskeySecret } from './admin'
import {
assertAndConsumeApiKeyTradeLimits,
assertApiKeyMethodAllowed,
} from '../security/trade-limits'
import { readSecret } from '../security/keyring'
import { effectiveSbiDeviceId, effectiveSbiTradePassword } from '../security/sbi-credentials'
const jsonText = (value: unknown) => JSON.stringify(value, null, 2)
const textResult = (value: unknown) => ({
content: [{ type: 'text' as const, text: typeof value === 'string' ? value : jsonText(value) }],
})
const requireAuthenticated = (auth: AuthContext) => {
if (!auth.authenticated) throw new Error('unauthorized')
}
const toolNameForMethod = (method: RpcMethod) => `csbie_sbi_${method.replaceAll('.', '_')}`
const ORDER_SUBMIT_TICKET_TTL_MS = 10 * 60 * 1000
type OrderSubmitTicket = {
passkeyId: string
estimateMethod: RpcMethod
submitMethod: RpcMethod
params: unknown
confirmationId?: string
authKey: string
expiresAt: Date
}
const orderSubmitTickets = new Map<string, OrderSubmitTicket>()
const authKey = (auth: AuthContext) => {
if (auth.type === 'apiKey') return `apiKey:${auth.apiKeyId}`
if (auth.type === 'session') return `session:${auth.sessionId}`
return 'none'
}
const cleanupExpiredOrderSubmitTickets = (now = new Date()) => {
for (const [uuid, ticket] of orderSubmitTickets) {
if (ticket.expiresAt <= now) orderSubmitTickets.delete(uuid)
}
}
const orderSubmitMethodByEstimateMethod = {
'orders.cash.estimate': 'orders.cash.place',
'orders.cash.estimateCorrection': 'orders.cash.placeCorrection',
'orders.cash.estimateCorrectionConfirm': 'orders.cash.placeCorrection',
'orders.cash.estimateCancel': 'orders.cash.placeCancel',
'orders.margin.estimateOpen': 'orders.margin.open',
'orders.margin.estimateClose': 'orders.margin.close',
'orders.margin.estimateCloseSummary': 'orders.margin.closeSummary',
'orders.margin.estimateSummary': 'orders.margin.placeSummary',
'orders.margin.estimateActualDelivery': 'orders.margin.actualDelivery',
'orders.ifd.estimate': 'orders.ifd.place',
'orders.ifd.estimateCorrection': 'orders.ifd.placeCorrection',
'orders.ifd.estimateCancel': 'orders.ifd.placeCancel',
'orders.themeInvestment.estimate': 'orders.themeInvestment.place',
} as const satisfies Partial<Record<RpcMethod, RpcMethod>>
const submitMethodForEstimateMethod = (method: RpcMethod) =>
orderSubmitMethodByEstimateMethod[method as keyof typeof orderSubmitMethodByEstimateMethod]
const isDirectOrderSubmitMethod = (method: RpcMethod) => isTradingMethod(method)
const mcpExposedRpcMethods = RPC_METHODS.filter((method) => !isDirectOrderSubmitMethod(method))
const orderSubmitParams = (value: unknown, confirmationId?: string) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return { allowTrading: true }
return {
...value,
...(confirmationId ? { confirmationId } : {}),
allowTrading: true,
}
}
const confirmationIdFromPreview = (value: unknown) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const confirmationId = (value as Record<string, unknown>).confirmationId
return typeof confirmationId === 'string' && confirmationId ? confirmationId : undefined
}
const accountTypeSchema = z.enum(['general', 'specific', 'nisa', 'juniorNisa', 'unknown'])
const depositTypeSchema = z.enum(['general', 'specific', 'nisa', 'juniorNisa', 'unknown'])
const tradeSideSchema = z.enum(['buy', 'sell'])
const marketCodeSchema = z.string().min(1).describe('SBI market code')
const issueCodeSchema = z.string().min(1).describe('Issue code')
const orderIdSchema = z.string().min(1).describe('Order id')
const positionIdSchema = z.string().min(1).describe('Position id')
const pagingSchema = {
index: z.number().int().min(0).optional().describe('Start index for the result list'),
limit: z.number().int().positive().optional().describe('Maximum number of items to fetch'),
}
const issueOptionsSchema = z.object({
issueCode: issueCodeSchema,
market: marketCodeSchema.optional(),
})
const issueChartOptionsSchema = issueOptionsSchema.extend({
period: z.enum(['minute', 'day', 'week', 'month']).optional().describe('Chart period'),
unit: z
.number()
.int()
.positive()
.optional()
.describe('Candle unit. Minute charts accept 1, 5, 10, or 15; other periods use 1'),
count: z
.number()
.int()
.positive()
.max(9999)
.optional()
.describe('Number of historical prices to request'),
})
const issueSearchOptionsSchema = z.object({
query: z.string().min(1).describe('Search text, such as an issue code, name, or keyword'),
market: marketCodeSchema.optional().describe('Client-side market code filter'),
limit: z.number().int().positive().optional().describe('Maximum number of returned issues'),
})
const cashPositionOptionsSchema = z.object({
...pagingSchema,
issueCode: issueCodeSchema.optional(),
market: marketCodeSchema.optional(),
accountType: accountTypeSchema.optional(),
})
const marginPositionOptionsSchema = z.object({
...pagingSchema,
issueCode: issueCodeSchema.optional(),
market: marketCodeSchema.optional(),
side: tradeSideSchema.optional(),
accountType: accountTypeSchema.optional(),
})
const orderInquiryOptionsSchema = z.object({
...pagingSchema,
from: z.string().optional().describe('Start date for the inquiry range'),
to: z.string().optional().describe('End date for the inquiry range'),
issueCode: issueCodeSchema.optional(),
market: marketCodeSchema.optional(),
status: z.enum(['open', 'executed', 'cancelled', 'expired', 'rejected', 'unknown']).optional(),
})
const boardOptionsSchema = issueOptionsSchema.extend({
accountType: accountTypeSchema.optional(),
side: z
.enum([
'cashBuy',
'cashSell',
'marginOpen',
'marginOpenBuy',
'marginOpenSell',
'marginClose',
'marginCloseBuy',
'marginCloseSell',
])
.optional(),
})
const stockOrderBaseSchema = z.object({
issueCode: issueCodeSchema,
market: marketCodeSchema,
side: tradeSideSchema,
accountType: accountTypeSchema.optional(),
quantity: z.number().positive().describe('Order quantity'),
depositType: depositTypeSchema.optional(),
})
const cashOrderPriceConditionSchema = z.enum([
'limit',
'limitAtOpen',
'limitAtClose',
'limitIoc',
'market',
'marketAtOpen',
'marketAtClose',
'marketIoc',
'funari',
])
const cashOrderSchema = stockOrderBaseSchema.extend({
price: z.number().positive().optional().describe('Order price for price-based orders'),
kind: z.enum(['market', 'limit', 'stop', 'oco', 'ifd', 'ifdo', 's', 'unknown']).optional(),
priceCondition: cashOrderPriceConditionSchema
.optional()
.describe('APK/MTS execution condition for cash orders'),
orderTerm: z.enum(['day', 'week', 'date']).optional().describe('Order validity term'),
orderDate: z
.string()
.optional()
.describe('Validity date used when orderTerm is date, in yyyyMMdd or yyyy-MM-dd format'),
orderMethod: z.enum(['normal', 'stop', 'oco']).optional().describe('Special order method'),
triggerZone: z.enum(['above', 'below']).optional().describe('Stop trigger direction'),
triggerPrice: z.number().positive().optional().describe('Stop trigger price'),
secondaryPriceCondition: cashOrderPriceConditionSchema
.optional()
.describe('Secondary execution condition for OCO orders'),
secondaryPrice: z.number().positive().optional().describe('Secondary price for OCO orders'),
sorLastMarket: marketCodeSchema
.optional()
.describe('Previous market code sent with SOR orders; defaults to login profile'),
})
const placeCashOrderSchema = cashOrderSchema.extend({
confirmationId: z
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
allowTrading: z.literal(true).optional().describe('Explicitly allows sending a live order'),
})
const orderCorrectionSchema = z.object({
orderId: orderIdSchema,
quantity: z.number().positive().optional().describe('Corrected order quantity'),
price: z.number().positive().optional().describe('Corrected order price'),
})
const placeOrderCorrectionSchema = orderCorrectionSchema.extend({
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live correction request'),
})
const orderCancelSchema = z.object({
orderNumber: z.string().min(1).describe('Order number shown in order inquiry'),
orderId: orderIdSchema.optional().describe('Original order id shown in order inquiry'),
tradeId: z.string().min(1).optional().describe('Original trade id code'),
cancelType: z.string().min(1).optional().describe('Additional cancel flag'),
})
const placeOrderCancelSchema = orderCancelSchema.extend({
tradePassword: z.string().optional().describe('Trading password used by SBI'),
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live cancellation request'),
})
const marginOpenOrderSchema = cashOrderSchema
const placeMarginOpenOrderSchema = marginOpenOrderSchema.extend({
confirmationId: z
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live margin open order'),
})
const marginCloseOrderSchema = cashOrderSchema.extend({
positionId: positionIdSchema.optional().describe('Position ID to close'),
})
const placeMarginCloseOrderSchema = marginCloseOrderSchema.extend({
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live margin close order'),
})
const actualDeliveryOrderSchema = z.object({
issueCode: issueCodeSchema,
market: marketCodeSchema,
accountType: accountTypeSchema.optional(),
quantity: z.number().positive().describe('Order quantity'),
depositType: depositTypeSchema.optional(),
price: z.number().positive().optional().describe('Order price for price-based requests'),
kind: z.enum(['genbiki', 'genwatashi']),
positionId: positionIdSchema.optional().describe('Position ID to deliver'),
})
const placeActualDeliveryOrderSchema = actualDeliveryOrderSchema.extend({
confirmationId: z
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live actual-delivery order'),
})
const ifdOrderSchema = cashOrderSchema.extend({
tradeType: z
.enum(['cash', 'marginOpen'])
.optional()
.describe('Product to use for the first IFD leg'),
})
const placeIfdOrderSchema = ifdOrderSchema.extend({
confirmationId: z
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
allowTrading: z.literal(true).optional().describe('Explicitly allows sending a live IFD order'),
})
const themeInvestmentOrderSchema = z.object({
themeId: z.string().min(1).describe('Theme ID for the theme investment order'),
side: tradeSideSchema,
amount: z.number().positive().optional().describe('Order amount for the theme investment order'),
})
const placeThemeInvestmentOrderSchema = themeInvestmentOrderSchema.extend({
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live theme investment order'),
})
const methodParamSchemas = {
'session.profile': undefined,
'account.profile': undefined,
'account.power.buyingPower': undefined,
'account.power.collateralRatio': undefined,
'account.positions.cash': cashPositionOptionsSchema.optional(),
'account.positions.cashDetail': cashPositionOptionsSchema.optional(),
'account.positions.cashForIssue': issueOptionsSchema,
'account.positions.margin': marginPositionOptionsSchema.optional(),
'account.positions.marginDetail': marginPositionOptionsSchema.optional(),
'account.positions.marginForIssue': issueOptionsSchema,
'account.positions.marginSummaryForIssue': issueOptionsSchema,
'account.positions.marginDetailsForIssue': issueOptionsSchema,
'account.positions.closeableMargin': marginPositionOptionsSchema,
'account.positions.deliverableMargin': marginPositionOptionsSchema,
'account.profitLoss.unrealized': undefined,
'market.issue.search': issueSearchOptionsSchema,
'market.issue.suggest': issueSearchOptionsSchema,
'market.issue.allowedPrices': issueOptionsSchema,
'market.issue.board': issueOptionsSchema,
'market.issue.chart': issueChartOptionsSchema,
'market.issue.openOrders': issueOptionsSchema,
'market.issue.tradingInfo': boardOptionsSchema,
'market.index.major': undefined,
'market.overview': undefined,
'market.ranking.market': undefined,
'market.ranking.sector': undefined,
'market.ranking.sbi': undefined,
'news.list': undefined,
'watchlist.list': undefined,
'orders.inquiry.executionsToday': orderInquiryOptionsSchema.optional(),
'orders.inquiry.open': orderInquiryOptionsSchema.optional(),
'orders.cash.estimate': cashOrderSchema,
'orders.cash.place': placeCashOrderSchema,
'orders.cash.estimateCorrection': orderCorrectionSchema,
'orders.cash.estimateCorrectionConfirm': orderCorrectionSchema,
'orders.cash.placeCorrection': placeOrderCorrectionSchema,
'orders.cash.estimateCancel': orderCancelSchema,
'orders.cash.placeCancel': placeOrderCancelSchema,
'orders.margin.estimateOpen': marginOpenOrderSchema,
'orders.margin.open': placeMarginOpenOrderSchema,
'orders.margin.estimateClose': marginCloseOrderSchema,
'orders.margin.close': placeMarginCloseOrderSchema,
'orders.margin.estimateCloseSummary': marginCloseOrderSchema,
'orders.margin.closeSummary': placeMarginCloseOrderSchema,
'orders.margin.estimateSummary': marginCloseOrderSchema,
'orders.margin.placeSummary': placeMarginCloseOrderSchema,
'orders.margin.estimateActualDelivery': actualDeliveryOrderSchema,
'orders.margin.actualDelivery': placeActualDeliveryOrderSchema,
'orders.ifd.estimate': ifdOrderSchema,
'orders.ifd.place': placeIfdOrderSchema,
'orders.ifd.estimateCorrection': orderCorrectionSchema,
'orders.ifd.placeCorrection': placeOrderCorrectionSchema,
'orders.ifd.estimateCancel': orderCorrectionSchema,
'orders.ifd.placeCancel': placeOrderCorrectionSchema,
'orders.themeInvestment.list': undefined,
'orders.themeInvestment.estimate': themeInvestmentOrderSchema,
'orders.themeInvestment.place': placeThemeInvestmentOrderSchema,
} satisfies Record<RpcMethod, z.ZodType | undefined>
const createMcpServer = (c: Context<AppBindings>) => {
const db = c.get('db')
const config = c.get('config')
const auth = c.get('auth')
const server = new McpServer({
name: 'csbie',
version: '0.1.0',
})
server.registerTool(
'csbie_sbi_methods',
{
title: 'List SBI RPC Methods',
description: 'List SBI client methods exposed through CSBIE.',
inputSchema: {},
},
async () => {
requireAuthenticated(auth)
return textResult({
methods: mcpExposedRpcMethods,
submitTool: 'csbie_sbi_submit_order',
})
},
)
server.registerTool(
'csbie_sbi_passkeys',
{
title: 'List SBI Passkeys',
description: 'List saved SBI passkey profiles. Secret material is never returned.',
inputSchema: {},
},
async () => {
requireAuthenticated(auth)
const rows = await db
.select({
id: sbiPasskeys.id,
label: sbiPasskeys.label,
keyringAccount: sbiPasskeys.keyringAccount,
createdAt: sbiPasskeys.createdAt,
updatedAt: sbiPasskeys.updatedAt,
})
.from(sbiPasskeys)
.orderBy(sbiPasskeys.createdAt)
const passkeys = await Promise.all(
rows.map(async ({ keyringAccount, ...row }) => {
const secret = await readSecret<StoredSbiPasskeySecret>(keyringAccount)
const hasDeviceId = Boolean(effectiveSbiDeviceId(secret))
const hasTradePassword = Boolean(effectiveSbiTradePassword(secret))
return {
...row,
hasTradePassword,
hasDeviceId,
cashOrderReady: hasTradePassword && hasDeviceId,
}
}),
)
return textResult({ passkeys })
},
)
const callSbiMethod = async (method: RpcMethod, passkeyId: string, params: unknown) => {
requireAuthenticated(auth)
if (auth.type === 'apiKey') {
await assertApiKeyMethodAllowed(db, auth.apiKeyId, method)
}
if (isTradingMethod(method)) {
const tradingParams = params as { allowTrading?: boolean } | undefined
if (!tradingParams?.allowTrading) throw new Error('trading methods require allowTrading')
if (auth.type === 'apiKey') {
await assertAndConsumeApiKeyTradeLimits({
db,
apiKeyId: auth.apiKeyId,
params,
})
}
}
if (isCashOrderMethod(method)) {
const [passkey] = await db
.select({ keyringAccount: sbiPasskeys.keyringAccount })
.from(sbiPasskeys)
.where(eq(sbiPasskeys.id, passkeyId))
.limit(1)
if (!passkey) throw new Error('SBI passkey not found')
const secret = await readSecret<StoredSbiPasskeySecret>(passkey.keyringAccount)
if (!effectiveSbiDeviceId(secret)) {
throw new Error(
'orders.cash methods require an SBI deviceId registered with F1131. This passkey has no saved deviceId, so MCP cannot complete SBI trade authentication for cash order estimates or orders.',
)
}
if (!effectiveSbiTradePassword(secret)) {
throw new Error(
'orders.cash methods require a saved SBI tradePassword. This passkey has no saved tradePassword, so MCP cannot complete cash order estimates or orders.',
)
}
}
const client = await connectSbi(db, config, passkeyId)
const result = await invokeSbiMethod(client, method, params)
const submitMethod = submitMethodForEstimateMethod(method)
if (!submitMethod) return textResult(result)
cleanupExpiredOrderSubmitTickets()
const uuid = randomUUID()
const expiresAt = new Date(Date.now() + ORDER_SUBMIT_TICKET_TTL_MS)
const confirmationId = confirmationIdFromPreview(result)
orderSubmitTickets.set(uuid, {
passkeyId,
estimateMethod: method,
submitMethod,
params,
confirmationId,
authKey: authKey(auth),
expiresAt,
})
return textResult({
preview: result,
submit: {
uuid,
tool: 'csbie_sbi_submit_order',
expiresAt: expiresAt.toISOString(),
},
})
}
server.registerTool(
'csbie_sbi_submit_order',
{
title: 'Submit Estimated SBI Order',
description:
'Submit the same SBI order as a previous MCP estimate result by UUID. The UUID expires shortly and is bound to the same authenticated caller.',
inputSchema: {
uuid: z.string().uuid().describe('UUID returned by an order estimate tool'),
},
},
async ({ uuid }) => {
requireAuthenticated(auth)
cleanupExpiredOrderSubmitTickets()
const ticket = orderSubmitTickets.get(uuid)
if (!ticket) throw new Error('order submit uuid not found or expired')
if (ticket.authKey !== authKey(auth)) {
throw new Error('order submit uuid was created by a different authenticated caller')
}
orderSubmitTickets.delete(uuid)
return callSbiMethod(
ticket.submitMethod,
ticket.passkeyId,
orderSubmitParams(ticket.params, ticket.confirmationId),
)
},
)
for (const method of mcpExposedRpcMethods) {
const paramsSchema = methodParamSchemas[method]
server.registerTool(
toolNameForMethod(method),
{
title: `Call ${method}`,
description: `Connect with one saved SBI passkey and call ${method}. API key method permissions and trading limits are enforced.`,
inputSchema: {
passkeyId: z.string().describe('Saved SBI passkey id from csbie_sbi_passkeys'),
...(paramsSchema
? {
params: paramsSchema.describe(`${method} params`),
}
: {}),
},
},
async ({ passkeyId, params }) => callSbiMethod(method, passkeyId, params),
)
}
return server
}
export const createMcpRoutes = () => {
const app = new Hono<AppBindings>()
app.all('/', async (c) => {
if (!c.get('authenticated')) {
const resourceMetadata = new URL('/.well-known/oauth-protected-resource/api/mcp', c.req.url)
c.header('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadata.toString()}"`)
return c.json({ error: 'unauthorized' }, 401)
}
const transport = new StreamableHTTPTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
})
const server = createMcpServer(c)
try {
await server.connect(transport)
return await transport.handleRequest(c)
} finally {
await server.close()
await transport.close()
}
})
return app
}
+90
View File
@@ -0,0 +1,90 @@
import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import type { AppBindings } from '../context'
import { oauthClients } from '../db/schema'
import type { ApiKeySettings } from '../security/api-keys'
import { createOAuthAuthorizationCode } from '../security/oauth-provider'
const loopbackHosts = new Set(['localhost', '127.0.0.1', '[::1]'])
const redirectUriAllowed = (redirectUri: string, registeredUris: string[]) => {
if (registeredUris.includes(redirectUri)) return true
let requested: URL
try {
requested = new URL(redirectUri)
} catch {
return false
}
if (!loopbackHosts.has(requested.hostname)) return false
return registeredUris.some((registeredUri) => {
try {
const registered = new URL(registeredUri)
return (
registered.protocol === requested.protocol &&
registered.hostname === requested.hostname &&
registered.pathname === requested.pathname &&
registered.search === requested.search
)
} catch {
return false
}
})
}
export const createOAuthRoutes = () => {
const app = new Hono<AppBindings>()
app.get('/client/:id', async (c) => {
if (!c.get('authenticated')) return c.json({ error: 'unauthorized' }, 401)
const [client] = await c
.get('db')
.select()
.from(oauthClients)
.where(eq(oauthClients.id, c.req.param('id')))
if (!client) return c.json({ error: 'client not found' }, 404)
return c.json({ client: client.client })
})
app.post('/approve', async (c) => {
if (!c.get('authenticated')) return c.json({ error: 'unauthorized' }, 401)
const body = await c.req.json<{
clientId?: string
redirectUri?: string
codeChallenge?: string
scope?: string
state?: string
resource?: string
settings?: ApiKeySettings
}>()
if (!body.clientId || !body.redirectUri || !body.codeChallenge) {
return c.json({ error: 'clientId, redirectUri and codeChallenge are required' }, 400)
}
const [client] = await c
.get('db')
.select()
.from(oauthClients)
.where(eq(oauthClients.id, body.clientId))
const redirectUris =
(client?.client as { redirect_uris?: string[] } | undefined)?.redirect_uris ?? []
if (!redirectUriAllowed(body.redirectUri, redirectUris)) {
return c.json({ error: 'redirectUri is not registered for this client' }, 400)
}
const code = await createOAuthAuthorizationCode(c.get('db'), {
clientId: body.clientId,
redirectUri: body.redirectUri,
codeChallenge: body.codeChallenge,
scopes: body.scope?.split(' ').filter(Boolean) ?? [],
resource: body.resource,
apiKeySettings: body.settings,
})
const redirectUrl = new URL(body.redirectUri)
redirectUrl.searchParams.set('code', code)
if (body.state) redirectUrl.searchParams.set('state', body.state)
return c.json({ redirectTo: redirectUrl.toString() })
})
return app
}
+112
View File
@@ -0,0 +1,112 @@
import type { SbiClientMethods } from '@repo/sbi-client'
export const RPC_METHODS = [
'session.profile',
'account.profile',
'account.power.buyingPower',
'account.power.collateralRatio',
'account.positions.cash',
'account.positions.cashDetail',
'account.positions.cashForIssue',
'account.positions.margin',
'account.positions.marginDetail',
'account.positions.marginForIssue',
'account.positions.marginSummaryForIssue',
'account.positions.marginDetailsForIssue',
'account.positions.closeableMargin',
'account.positions.deliverableMargin',
'account.profitLoss.unrealized',
'market.issue.search',
'market.issue.suggest',
'market.issue.allowedPrices',
'market.issue.board',
'market.issue.chart',
'market.issue.openOrders',
'market.issue.tradingInfo',
'market.index.major',
'market.overview',
'market.ranking.market',
'market.ranking.sector',
'market.ranking.sbi',
'news.list',
'watchlist.list',
'orders.inquiry.executionsToday',
'orders.inquiry.open',
'orders.cash.estimate',
'orders.cash.place',
'orders.cash.estimateCorrection',
'orders.cash.estimateCorrectionConfirm',
'orders.cash.placeCorrection',
'orders.cash.estimateCancel',
'orders.cash.placeCancel',
'orders.margin.estimateOpen',
'orders.margin.open',
'orders.margin.estimateClose',
'orders.margin.close',
'orders.margin.estimateCloseSummary',
'orders.margin.closeSummary',
'orders.margin.estimateSummary',
'orders.margin.placeSummary',
'orders.margin.estimateActualDelivery',
'orders.margin.actualDelivery',
'orders.ifd.estimate',
'orders.ifd.place',
'orders.ifd.estimateCorrection',
'orders.ifd.placeCorrection',
'orders.ifd.estimateCancel',
'orders.ifd.placeCancel',
'orders.themeInvestment.list',
'orders.themeInvestment.estimate',
'orders.themeInvestment.place',
] as const
export type RpcMethod = (typeof RPC_METHODS)[number]
const methodSet = new Set<string>(RPC_METHODS)
const tradingMethods = new Set<string>([
'orders.cash.place',
'orders.cash.placeCorrection',
'orders.cash.placeCancel',
'orders.margin.open',
'orders.margin.close',
'orders.margin.closeSummary',
'orders.margin.placeSummary',
'orders.margin.actualDelivery',
'orders.ifd.place',
'orders.ifd.placeCorrection',
'orders.ifd.placeCancel',
'orders.themeInvestment.place',
])
const cashOrderMethods = new Set<string>([
'orders.cash.estimate',
'orders.cash.place',
'orders.cash.estimateCorrection',
'orders.cash.estimateCorrectionConfirm',
'orders.cash.placeCorrection',
'orders.cash.estimateCancel',
'orders.cash.placeCancel',
])
export const isRpcMethod = (method: string): method is RpcMethod => methodSet.has(method)
export const isTradingMethod = (method: string) => tradingMethods.has(method)
export const isCashOrderMethod = (method: string) => cashOrderMethods.has(method)
export const invokeSbiMethod = async (
client: SbiClientMethods,
method: RpcMethod,
params: unknown,
) => {
const target = method.split('.').reduce<unknown>((value, key) => {
if (!value || typeof value !== 'object') return undefined
return (value as Record<string, unknown>)[key]
}, client)
if (typeof target !== 'function') throw new Error(`RPC method not callable: ${method}`)
if (Array.isArray(params)) return target(...params)
if (params === undefined || params === null) return target()
return target(params)
}
+33
View File
@@ -0,0 +1,33 @@
import { eq } from 'drizzle-orm'
import { loginWithPasskey } from '@repo/sbi-client'
import type { SbiClientMethods, SbiClientOptions } from '@repo/sbi-client'
import type { ServerConfig } from '../config'
import type { Db } from '../db'
import { sbiPasskeys } from '../db/schema'
import type { StoredSbiPasskeySecret } from '../routes/admin'
import { readSecret } from '../security/keyring'
import { effectiveSbiDeviceId, effectiveSbiTradePassword } from '../security/sbi-credentials'
export const connectSbi = async (
db: Db,
config: ServerConfig,
passkeyId: string,
): Promise<SbiClientMethods> => {
const [row] = await db.select().from(sbiPasskeys).where(eq(sbiPasskeys.id, passkeyId)).limit(1)
if (!row) throw new Error('SBI passkey not found')
const secret = await readSecret<StoredSbiPasskeySecret>(row.keyringAccount)
const clientOptions: SbiClientOptions = {
tradePassword: effectiveSbiTradePassword(secret),
deviceId: effectiveSbiDeviceId(secret),
}
return loginWithPasskey(
{
passkeyCredential: secret.credential,
authBaseUrl: config.authBaseUrl,
mtsBaseUrl: config.mtsBaseUrl,
izanagiBaseUrl: config.izanagiBaseUrl,
},
clientOptions,
)
}
+253
View File
@@ -0,0 +1,253 @@
import type { SbiClientMethods } from '@repo/sbi-client'
import { createBunWebSocket } from 'hono/bun'
import type { WSContext } from 'hono/ws'
import { randomUUID } from 'node:crypto'
import type { ServerConfig } from '../config'
import type { Db } from '../db'
import {
assertAndConsumeApiKeyTradeLimits,
assertApiKeyMethodAllowed,
} from '../security/trade-limits'
import { invokeSbiMethod, isRpcMethod, isTradingMethod, RPC_METHODS } from './methods'
import { connectSbi } from './sbi-session'
type JsonRpcRequest = {
jsonrpc?: '2.0'
id?: string | number | null
method?: string
params?: unknown
}
type RpcSocketState = {
client?: SbiClientMethods
sbiPasskeyId?: string
apiKeyId?: string
boardPollingSubscriptions: Map<string, AbortController>
}
type BoardPollingParams = {
issueCode: string
market?: string
intervalSeconds?: number
}
const BOARD_POLLING_METHODS = [
'market.issue.pollBoard.subscribe',
'market.issue.pollBoard.unsubscribe',
] as const
const send = (ws: WSContext, payload: unknown) => {
ws.send(JSON.stringify(payload))
}
const result = (id: JsonRpcRequest['id'], value: unknown) => ({
jsonrpc: '2.0',
id: id ?? null,
result: value,
})
const error = (id: JsonRpcRequest['id'], code: number, message: string) => ({
jsonrpc: '2.0',
id: id ?? null,
error: { code, message },
})
const notification = (method: string, params: unknown) => ({
jsonrpc: '2.0',
method,
params,
})
const parseBoardPollingParams = (params: unknown): BoardPollingParams => {
if (!params || typeof params !== 'object' || Array.isArray(params)) {
throw new Error('pollBoard params are required')
}
const value = params as Record<string, unknown>
if (typeof value.issueCode !== 'string' || !value.issueCode) {
throw new Error('issueCode is required')
}
if (value.market != null && typeof value.market !== 'string') {
throw new Error('market must be a string')
}
if (
value.intervalSeconds != null &&
(typeof value.intervalSeconds !== 'number' ||
!Number.isFinite(value.intervalSeconds) ||
value.intervalSeconds <= 0)
) {
throw new Error('intervalSeconds must be a positive finite number')
}
return {
issueCode: value.issueCode,
market: typeof value.market === 'string' ? value.market : undefined,
intervalSeconds: typeof value.intervalSeconds === 'number' ? value.intervalSeconds : undefined,
}
}
const stopBoardPollingSubscription = (state: RpcSocketState, subscriptionId: string) => {
const controller = state.boardPollingSubscriptions.get(subscriptionId)
if (!controller) return false
state.boardPollingSubscriptions.delete(subscriptionId)
controller.abort(new Error('market issue board polling unsubscribed'))
return true
}
const stopBoardPollingSubscriptions = (state: RpcSocketState) => {
for (const subscriptionId of state.boardPollingSubscriptions.keys()) {
stopBoardPollingSubscription(state, subscriptionId)
}
}
const subscribeBoardPolling = async (
db: Db,
state: RpcSocketState,
ws: WSContext,
request: JsonRpcRequest,
) => {
if (!state.client) return error(request.id, 4001, 'SBI session is not connected')
if (state.apiKeyId) {
await assertApiKeyMethodAllowed(db, state.apiKeyId, 'market.issue.board')
}
const params = parseBoardPollingParams(request.params)
const subscriptionId = randomUUID()
const controller = new AbortController()
state.boardPollingSubscriptions.set(subscriptionId, controller)
void (async () => {
try {
for await (const board of state.client!.market.issue.pollBoard({
...params,
signal: controller.signal,
})) {
if (!state.boardPollingSubscriptions.has(subscriptionId)) return
send(ws, notification('market.issue.pollBoard.update', { subscriptionId, board }))
}
} catch (cause) {
if (!controller.signal.aborted) {
send(
ws,
notification('market.issue.pollBoard.error', {
subscriptionId,
message: cause instanceof Error ? cause.message : 'pollBoard failed',
}),
)
}
} finally {
state.boardPollingSubscriptions.delete(subscriptionId)
}
})()
return result(request.id, { subscriptionId })
}
const handleRpc = async (
db: Db,
config: ServerConfig,
state: RpcSocketState,
ws: WSContext,
request: JsonRpcRequest,
) => {
if (request.method === 'rpc.methods') {
return result(request.id, [...RPC_METHODS, ...BOARD_POLLING_METHODS])
}
if (request.method === 'sbi.connect') {
const passkeyId =
request.params && typeof request.params === 'object'
? (request.params as { passkeyId?: string }).passkeyId
: undefined
if (!passkeyId) throw new Error('passkeyId is required')
stopBoardPollingSubscriptions(state)
state.client = await connectSbi(db, config, passkeyId)
state.sbiPasskeyId = passkeyId
return result(request.id, { connected: true, passkeyId })
}
if (request.method === 'market.issue.pollBoard.subscribe') {
return subscribeBoardPolling(db, state, ws, request)
}
if (request.method === 'market.issue.pollBoard.unsubscribe') {
const subscriptionId =
request.params && typeof request.params === 'object'
? (request.params as { subscriptionId?: string }).subscriptionId
: undefined
if (!subscriptionId) throw new Error('subscriptionId is required')
return result(request.id, {
unsubscribed: stopBoardPollingSubscription(state, subscriptionId),
})
}
if (!request.method || !isRpcMethod(request.method)) {
return error(request.id, -32601, 'method not found')
}
if (!state.client) return error(request.id, 4001, 'SBI session is not connected')
if (state.apiKeyId) {
await assertApiKeyMethodAllowed(db, state.apiKeyId, request.method)
}
if (isTradingMethod(request.method)) {
const params = request.params as { allowTrading?: boolean } | undefined
if (!params?.allowTrading)
return error(request.id, 4002, 'trading methods require allowTrading')
if (state.apiKeyId) {
await assertAndConsumeApiKeyTradeLimits({
db,
apiKeyId: state.apiKeyId,
params: request.params,
})
}
}
return result(request.id, await invokeSbiMethod(state.client, request.method, request.params))
}
export const createRpcWebSocket = (db: Db, config: ServerConfig) => {
const { upgradeWebSocket, websocket } = createBunWebSocket()
return {
websocket,
upgradeWebSocket: upgradeWebSocket(async (c) => {
if (!c.get('authenticated')) {
return {
onOpen(_event, ws) {
ws.close(1008, 'unauthorized')
},
}
}
const auth = c.get('auth')
const state: RpcSocketState = {
apiKeyId: auth.type === 'apiKey' ? auth.apiKeyId : undefined,
boardPollingSubscriptions: new Map(),
}
return {
onOpen(_event, ws) {
send(ws, result(null, { connected: true, methods: ['rpc.methods', 'sbi.connect'] }))
},
async onMessage(event, ws) {
let request: JsonRpcRequest | undefined
try {
request = JSON.parse(String(event.data)) as JsonRpcRequest
send(ws, await handleRpc(db, config, state, ws, request))
} catch (cause) {
send(
ws,
error(request?.id, -32603, cause instanceof Error ? cause.message : 'internal error'),
)
}
},
onClose() {
stopBoardPollingSubscriptions(state)
},
}
}),
}
}
@@ -0,0 +1,86 @@
import { and, eq, isNull } from 'drizzle-orm'
import type { Db } from '../db'
import { apiKeys } from '../db/schema'
import { randomId, randomToken, sha256 } from './crypto'
export type ApiKeySettings = {
maxTradesPerHour?: number | null
maxTradesPer6Hours?: number | null
maxTradesPerDay?: number | null
maxOrderPriceJpy?: number | null
maxOrderAmountJpy?: number | null
allowedMethods?: string[] | null
}
const normalizeLimit = (value: unknown) => {
if (value === undefined || value === null || value === '') return null
const number = Number(value)
if (!Number.isFinite(number) || number < 0) throw new Error('limit must be a positive number')
return Math.floor(number)
}
export const normalizeApiKeySettings = (settings: ApiKeySettings = {}) => ({
maxTradesPerHour: normalizeLimit(settings.maxTradesPerHour),
maxTradesPer6Hours: normalizeLimit(settings.maxTradesPer6Hours),
maxTradesPerDay: normalizeLimit(settings.maxTradesPerDay),
maxOrderPriceJpy: normalizeLimit(settings.maxOrderPriceJpy),
maxOrderAmountJpy: normalizeLimit(settings.maxOrderAmountJpy),
allowedMethods:
settings.allowedMethods === undefined
? null
: settings.allowedMethods?.length
? [...new Set(settings.allowedMethods)].sort()
: null,
})
export const listApiKeys = async (db: Db) =>
db
.select({
id: apiKeys.id,
label: apiKeys.label,
maxTradesPerHour: apiKeys.maxTradesPerHour,
maxTradesPer6Hours: apiKeys.maxTradesPer6Hours,
maxTradesPerDay: apiKeys.maxTradesPerDay,
maxOrderPriceJpy: apiKeys.maxOrderPriceJpy,
maxOrderAmountJpy: apiKeys.maxOrderAmountJpy,
allowedMethods: apiKeys.allowedMethods,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
revokedAt: apiKeys.revokedAt,
})
.from(apiKeys)
.orderBy(apiKeys.createdAt)
export const createApiKey = async (db: Db, label: string, settings: ApiKeySettings = {}) => {
const token = `csbie_${randomToken()}`
const now = new Date()
const row = {
id: randomId('key'),
label,
tokenHash: sha256(token),
...normalizeApiKeySettings(settings),
createdAt: now,
}
await db.insert(apiKeys).values(row)
return { ...row, token, tokenHash: undefined }
}
export const updateApiKeySettings = async (db: Db, id: string, settings: ApiKeySettings) => {
await db.update(apiKeys).set(normalizeApiKeySettings(settings)).where(eq(apiKeys.id, id))
}
export const revokeApiKey = async (db: Db, id: string) => {
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id))
}
export const verifyApiKey = async (db: Db, token: string) => {
const tokenHash = sha256(token)
const [row] = await db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.tokenHash, tokenHash), isNull(apiKeys.revokedAt)))
.limit(1)
if (!row) return undefined
await db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id))
return row
}
+13
View File
@@ -0,0 +1,13 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
export const randomId = (prefix: string) => `${prefix}_${randomBytes(18).toString('base64url')}`
export const randomToken = () => randomBytes(32).toString('base64url')
export const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')
export const safeEqual = (left: string, right: string) => {
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer)
}
@@ -0,0 +1,47 @@
import { eq } from 'drizzle-orm'
import type { ServerConfig } from '../config'
import type { Db } from '../db'
import { sessions } from '../db/schema'
import { verifyApiKey } from './api-keys'
import type { AuthContext } from '../context'
const readCookie = (header: string | null, name: string) => {
if (!header) return undefined
for (const part of header.split(';')) {
const [key, ...value] = part.trim().split('=')
if (key === name) return decodeURIComponent(value.join('='))
}
return undefined
}
const readQueryApiKey = (request: Request) => {
const key = new URL(request.url).searchParams.get('key')?.trim()
return key || undefined
}
const apiKeyAuth = async (db: Db, token: string) => {
const apiKey = await verifyApiKey(db, token)
return apiKey
? ({ type: 'apiKey', authenticated: true, apiKeyId: apiKey.id } satisfies AuthContext)
: ({ type: 'none', authenticated: false } satisfies AuthContext)
}
export const authenticateRequest = async (db: Db, config: ServerConfig, request: Request) => {
const authorization = request.headers.get('authorization')
if (authorization?.startsWith('Bearer ')) {
return apiKeyAuth(db, authorization.slice('Bearer '.length))
}
const queryApiKey = readQueryApiKey(request)
if (queryApiKey) {
return apiKeyAuth(db, queryApiKey)
}
const sessionId = readCookie(request.headers.get('cookie'), config.sessionCookieName)
if (!sessionId) return { type: 'none', authenticated: false } satisfies AuthContext
const [session] = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1)
if (!session || session.expiresAt <= new Date()) {
return { type: 'none', authenticated: false } satisfies AuthContext
}
return { type: 'session', authenticated: true, sessionId } satisfies AuthContext
}
+17
View File
@@ -0,0 +1,17 @@
import { getPassword, setPassword, deletePassword } from '@napi-rs/keyring/keytar'
const SERVICE = 'csbie'
export const saveSecret = async (account: string, secret: unknown) => {
await setPassword(SERVICE, account, JSON.stringify(secret))
}
export const readSecret = async <T>(account: string): Promise<T> => {
const secret = await getPassword(SERVICE, account)
if (!secret) throw new Error(`secret not found: ${account}`)
return JSON.parse(secret) as T
}
export const deleteSecret = async (account: string) => {
await deletePassword(SERVICE, account)
}
@@ -0,0 +1,191 @@
import { and, eq, isNull } from 'drizzle-orm'
import type { Context } from 'hono'
import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js'
import type {
OAuthClientInformationFull,
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js'
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
import type { ServerConfig } from '../config'
import type { AppBindings } from '../context'
import type { Db } from '../db'
import { apiKeys, oauthAuthorizationCodes, oauthClients, oauthRefreshTokens } from '../db/schema'
import { createApiKey, type ApiKeySettings, verifyApiKey } from './api-keys'
import { randomToken, sha256 } from './crypto'
const CODE_TTL_MS = 10 * 60 * 1000
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000
export const createOAuthAuthorizationCode = async (
db: Db,
options: {
clientId: string
redirectUri: string
codeChallenge: string
scopes: string[]
resource?: string
apiKeySettings?: ApiKeySettings
},
) => {
const now = new Date()
const code = `mcp_code_${randomToken()}`
await db.insert(oauthAuthorizationCodes).values({
code,
clientId: options.clientId,
redirectUri: options.redirectUri,
codeChallenge: options.codeChallenge,
scopes: options.scopes,
resource: options.resource,
apiKeySettings: (options.apiKeySettings ?? null) as Record<string, unknown> | null,
createdAt: now,
expiresAt: new Date(now.getTime() + CODE_TTL_MS),
})
return code
}
export const createOAuthServerProvider = (db: Db, config: ServerConfig): OAuthServerProvider => ({
get clientsStore() {
return {
getClient: async (clientId: string) => {
const [row] = await db.select().from(oauthClients).where(eq(oauthClients.id, clientId))
return row?.client as OAuthClientInformationFull | undefined
},
registerClient: async (
client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
) => {
const clientInfo = client as OAuthClientInformationFull
await db
.insert(oauthClients)
.values({
id: clientInfo.client_id,
client: clientInfo as unknown as Record<string, unknown>,
createdAt: new Date(),
})
.onConflictDoUpdate({
target: oauthClients.id,
set: { client: clientInfo as unknown as Record<string, unknown> },
})
return clientInfo
},
}
},
authorize: async (client, params, c: Context<AppBindings>) => {
const approvalUrl = new URL('/oauth/authorize', config.origin)
approvalUrl.searchParams.set('client_id', client.client_id)
approvalUrl.searchParams.set('redirect_uri', params.redirectUri)
approvalUrl.searchParams.set('code_challenge', params.codeChallenge)
if (params.state) approvalUrl.searchParams.set('state', params.state)
if (params.scopes?.length) approvalUrl.searchParams.set('scope', params.scopes.join(' '))
if (params.resource) approvalUrl.searchParams.set('resource', params.resource.href)
const auth = c.get('auth')
if (!auth?.authenticated || auth.type !== 'session') {
approvalUrl.searchParams.set('login_required', '1')
c.res = c.redirect(approvalUrl.toString(), 302)
return
}
c.res = c.redirect(approvalUrl.toString(), 302)
},
challengeForAuthorizationCode: async (_client, authorizationCode) => {
const [row] = await db
.select()
.from(oauthAuthorizationCodes)
.where(eq(oauthAuthorizationCodes.code, authorizationCode))
if (!row || row.expiresAt <= new Date()) throw new Error('authorization code expired')
return row.codeChallenge
},
exchangeAuthorizationCode: async (
client,
authorizationCode,
_codeVerifier,
redirectUri,
resource,
) => {
const [code] = await db
.select()
.from(oauthAuthorizationCodes)
.where(eq(oauthAuthorizationCodes.code, authorizationCode))
if (!code || code.expiresAt <= new Date()) throw new Error('authorization code expired')
if (code.clientId !== client.client_id) throw new Error('authorization code client mismatch')
if (redirectUri && code.redirectUri !== redirectUri) throw new Error('redirect_uri mismatch')
await db
.delete(oauthAuthorizationCodes)
.where(eq(oauthAuthorizationCodes.code, authorizationCode))
const key = await createApiKey(
db,
`OAuth: ${client.client_name ?? client.client_id}`,
(code.apiKeySettings ?? {}) as ApiKeySettings,
)
const refreshToken = `csbie_refresh_${randomToken()}`
const now = new Date()
const scopes = code.scopes
await db.insert(oauthRefreshTokens).values({
tokenHash: sha256(refreshToken),
clientId: client.client_id,
apiKeyId: key.id,
scopes,
resource: resource?.href ?? code.resource,
createdAt: now,
expiresAt: new Date(now.getTime() + REFRESH_TOKEN_TTL_MS),
})
return {
access_token: key.token,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_SECONDS,
refresh_token: refreshToken,
scope: scopes.join(' '),
} satisfies OAuthTokens
},
exchangeRefreshToken: async (client, refreshToken, scopes, _resource) => {
const [token] = await db
.select()
.from(oauthRefreshTokens)
.where(
and(
eq(oauthRefreshTokens.tokenHash, sha256(refreshToken)),
eq(oauthRefreshTokens.clientId, client.client_id),
isNull(oauthRefreshTokens.revokedAt),
),
)
if (!token || token.expiresAt <= new Date()) throw new Error('refresh token expired')
const newAccess = await createApiKey(db, `OAuth: ${client.client_name ?? client.client_id}`)
return {
access_token: newAccess.token,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_SECONDS,
scope: (scopes ?? token.scopes).join(' '),
refresh_token: refreshToken,
} satisfies OAuthTokens
},
verifyAccessToken: async (token) => {
const apiKey = await verifyApiKey(db, token)
if (!apiKey) throw new Error('invalid access token')
return {
token,
clientId: apiKey.id,
scopes: ['mcp'],
expiresAt: Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS,
} satisfies AuthInfo
},
revokeToken: async (_client, request) => {
await db
.update(apiKeys)
.set({ revokedAt: new Date() })
.where(eq(apiKeys.tokenHash, sha256(request.token)))
await db
.update(oauthRefreshTokens)
.set({ revokedAt: new Date() })
.where(eq(oauthRefreshTokens.tokenHash, sha256(request.token)))
},
})
@@ -0,0 +1,11 @@
import type { StoredSbiPasskeySecret } from '../routes/admin'
const nonEmpty = (value: string | undefined) => value?.trim() || undefined
export const effectiveSbiDeviceId = (secret: StoredSbiPasskeySecret) =>
nonEmpty(secret.deviceId) ?? nonEmpty(process.env.SBI_DEVICE_ID)
export const effectiveSbiTradePassword = (secret: StoredSbiPasskeySecret) =>
nonEmpty(secret.tradePassword) ?? nonEmpty(process.env.SBI_TRADE_PASSWORD)
export const hasNonEmptySecretValue = (value: string | undefined) => Boolean(nonEmpty(value))
@@ -0,0 +1,44 @@
import { eq, lt } from 'drizzle-orm'
import type { Context } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import type { ServerConfig } from '../config'
import type { Db } from '../db'
import { sessions } from '../db/schema'
import { randomId } from './crypto'
const SESSION_DAYS = 30
export const createSession = async (db: Db) => {
const now = new Date()
const expiresAt = new Date(now.getTime() + SESSION_DAYS * 24 * 60 * 60 * 1000)
const id = randomId('ses')
await db.insert(sessions).values({ id, createdAt: now, expiresAt })
return { id, expiresAt }
}
export const setSessionCookie = (c: Context, config: ServerConfig, id: string, expires: Date) => {
setCookie(c, config.sessionCookieName, id, {
httpOnly: true,
sameSite: 'Lax',
secure: config.origin.startsWith('https://'),
path: '/',
expires,
})
}
export const clearSessionCookie = async (c: Context, db: Db, config: ServerConfig) => {
const id = getCookie(c, config.sessionCookieName)
if (id) await db.delete(sessions).where(eq(sessions.id, id))
deleteCookie(c, config.sessionCookieName, { path: '/' })
}
export const verifySessionCookie = async (c: Context, db: Db, config: ServerConfig) => {
const id = getCookie(c, config.sessionCookieName)
if (!id) return false
const [session] = await db.select().from(sessions).where(eq(sessions.id, id)).limit(1)
return Boolean(session && session.expiresAt > new Date())
}
export const pruneSessions = async (db: Db) => {
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()))
}
+11
View File
@@ -0,0 +1,11 @@
import { sha256, safeEqual } from './crypto'
export const verifySetupPassword = (password: string) => {
const hash = process.env.CSBIE_SETUP_PASSWORD_HASH
if (hash) return safeEqual(sha256(password), hash)
const plaintext = process.env.CSBIE_SETUP_PASSWORD
if (plaintext) return safeEqual(password, plaintext)
return false
}
@@ -0,0 +1,117 @@
import { and, eq } from 'drizzle-orm'
import type { Db } from '../db'
import { apiKeyTradeUsage, apiKeys } from '../db/schema'
import type { RpcMethod } from '../rpc/methods'
type WindowName = '1h' | '3h' | '1d'
type TradeLimitInput = {
apiKeyId: string
params: unknown
}
const windowBucket = (date: Date, window: WindowName) => {
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
if (window === '1d') return `${year}-${month}-${day}`
const hour = date.getUTCHours()
const bucketHour = window === '3h' ? Math.floor(hour / 3) * 3 : hour
return `${year}-${month}-${day}T${String(bucketHour).padStart(2, '0')}`
}
const numberFromParams = (params: unknown, key: string) => {
if (!params || typeof params !== 'object' || Array.isArray(params)) return undefined
const value = (params as Record<string, unknown>)[key]
if (value === undefined || value === null || value === '') return undefined
const number = Number(value)
return Number.isFinite(number) ? number : undefined
}
const assertPriceLimits = (
params: unknown,
settings: {
maxOrderPriceJpy: number | null
maxOrderAmountJpy: number | null
},
) => {
const price = numberFromParams(params, 'price')
const quantity = numberFromParams(params, 'quantity')
if (settings.maxOrderPriceJpy != null && price != null && price > settings.maxOrderPriceJpy) {
throw new Error(`order price exceeds API key limit (${settings.maxOrderPriceJpy} JPY)`)
}
if (settings.maxOrderAmountJpy == null) return
if (price == null || quantity == null) {
throw new Error('order amount limit requires price and quantity in trading params')
}
const amount = price * quantity
if (amount > settings.maxOrderAmountJpy) {
throw new Error(`order amount exceeds API key limit (${settings.maxOrderAmountJpy} JPY)`)
}
}
const checkAndConsumeWindow = async (
db: Db,
apiKeyId: string,
window: WindowName,
limit: number | null,
now: Date,
) => {
if (limit == null) return
const hourBucket = windowBucket(now, window)
const where = and(
eq(apiKeyTradeUsage.apiKeyId, apiKeyId),
eq(apiKeyTradeUsage.window, window),
eq(apiKeyTradeUsage.hourBucket, hourBucket),
)
const [usage] = await db.select().from(apiKeyTradeUsage).where(where).limit(1)
const currentCount = usage?.tradeCount ?? 0
if (currentCount >= limit) throw new Error(`${window} trade limit exceeded for API key`)
if (usage) {
await db
.update(apiKeyTradeUsage)
.set({ tradeCount: currentCount + 1, updatedAt: now })
.where(where)
return
}
await db.insert(apiKeyTradeUsage).values({
apiKeyId,
window,
hourBucket,
tradeCount: 1,
updatedAt: now,
})
}
export const assertAndConsumeApiKeyTradeLimits = async ({
db,
apiKeyId,
params,
}: TradeLimitInput & { db: Db }) => {
const [key] = await db.select().from(apiKeys).where(eq(apiKeys.id, apiKeyId)).limit(1)
if (!key || key.revokedAt) throw new Error('API key is not active')
assertPriceLimits(params, {
maxOrderPriceJpy: key.maxOrderPriceJpy,
maxOrderAmountJpy: key.maxOrderAmountJpy,
})
const now = new Date()
await checkAndConsumeWindow(db, apiKeyId, '1h', key.maxTradesPerHour, now)
await checkAndConsumeWindow(db, apiKeyId, '3h', key.maxTradesPer6Hours, now)
await checkAndConsumeWindow(db, apiKeyId, '1d', key.maxTradesPerDay, now)
}
export const assertApiKeyMethodAllowed = async (db: Db, apiKeyId: string, method: RpcMethod) => {
const [key] = await db.select().from(apiKeys).where(eq(apiKeys.id, apiKeyId)).limit(1)
if (!key || key.revokedAt) throw new Error('API key is not active')
if (key.allowedMethods === null || key.allowedMethods === undefined) return
if (key.allowedMethods.includes(method)) return
throw new Error(`API key is not allowed to call ${method}`)
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"rootDir": "src",
"outDir": "dist",
"noEmit": true
},
"include": ["src/**/*.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CSBIE</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@repo/csbie-ui",
"private": true,
"type": "module",
"scripts": {
"clean": "rm -rf dist",
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@simplewebauthn/browser": "^13.1.2",
"lucide-vue-next": "^1.0.0",
"motion-v": "^2.3.0",
"tailwindcss": "^4.1.10",
"vite": "^8",
"vue": "^3.5.16",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "^5",
"vue-tsc": "^3"
}
}
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
import { AnimatePresence } from 'motion-v'
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { revokeApiKey } from './api'
import AppHeader from './components/layout/AppHeader.vue'
import AppSidebar from './components/layout/AppSidebar.vue'
import SearchDialog from './features/search/SearchDialog.vue'
import AuthGate from './features/auth/AuthGate.vue'
import { useAuthAdmin } from './features/auth/useAuthAdmin'
import HistoryView from './features/history/HistoryView.vue'
import OAuthApprovalPanel from './features/oauth/OAuthApprovalPanel.vue'
import { useOAuthApproval } from './features/oauth/useOAuthApproval'
import OrderDialogs from './features/orders/OrderDialogs.vue'
import PortfolioView from './features/portfolio/PortfolioView.vue'
import SettingsView from './features/settings/SettingsView.vue'
import TradeView from './features/trade/TradeView.vue'
import { useTradingSession } from './features/trading/useTradingSession'
import { routeNames, type RouteName } from './router'
import { ui } from './styles/ui'
const route = useRoute()
const router = useRouter()
const activeTab = computed<RouteName>(() => {
return routeNames.includes(route.name as RouteName) ? (route.name as RouteName) : 'portfolio'
})
const showAuthGate = computed(() => true)
const navigate = (name: RouteName) => router.push({ name })
const {
status,
apiKeys,
sbiPasskeys,
selectedPasskeyId,
setupPassword,
authBusy,
apiKeyLabel,
newApiKeySettings,
newApiToken,
sbiLabel,
sbiCredentialJson,
tradePassword,
sbiDeviceId,
refresh,
addApiKey,
saveApiKeySettings,
setupOwnerPasskey,
loginWithPasskey,
addSbiPasskey,
removeSbiPasskey,
} = useAuthAdmin()
const {
selectedStockCode,
tradeSide,
orderKind,
cashOrderAccountType,
cashOrderMarket,
cashOrderPriceCondition,
cashOrderTerm,
cashOrderDateInput,
cashOrderMethod,
cashOrderTriggerZone,
cashOrderTriggerPriceInput,
cashOrderSecondaryPriceCondition,
cashOrderSecondaryPriceInput,
quantityInput,
priceInput,
chartMode,
showSearch,
searchQuery,
countryFilter,
marketFilter,
showEstimateDialog,
showOrderDialog,
lastCashEstimate,
connected,
dataLoading,
searchLoading,
buyingPower,
holdingsMarketValue,
totalProfitLoss,
totalProfitLossRate,
orders,
orderHistoryLoaded,
orderHistoryNotice,
positions,
chartPricePoints,
pricePolling,
selectedStock,
orderQuantity,
orderPrice,
cashOrderPrimaryRequiresPrice,
cashOrderTriggerPrice,
cashOrderSecondaryPrice,
estimatedAmount,
showPortfolioSpinner,
canRequestCashEstimate,
canPlaceCashOrder,
countries,
markets,
viewedStocks,
filteredStocks,
selectedPosition,
recentOrders,
totalAssetValue,
stockAssetRatio,
cashAssetRatio,
boxPlotStyle,
hasQuote,
selectStock,
connect,
loadTradingData,
estimateCashOrder,
askPlaceOrder,
placeCashOrder,
cancelOrder,
downloadCsv,
openTradeForStock,
openTradeForPosition,
} = useTradingSession(selectedPasskeyId)
const { oauthApproval, oauthSettings, loadOAuthApproval, approveOAuth } = useOAuthApproval()
const refreshAndMaybeConnect = () =>
refresh({
autoConnect: true,
connect: () => {
if (!connected.value) connect()
},
})
const addSbiPasskeyAndConnect = async () => {
await addSbiPasskey()
connect()
}
const revokeAndRefresh = async (id: string) => {
await revokeApiKey(id)
await refresh()
}
onMounted(async () => {
try {
await refreshAndMaybeConnect()
} catch {
status.value = { configured: false, authenticated: false }
}
await loadOAuthApproval()
})
</script>
<template>
<main :class="ui.appShell">
<AppSidebar :active-tab="activeTab" @navigate="navigate" />
<section :class="ui.workspace">
<AppHeader :active-tab="activeTab" />
<OAuthApprovalPanel
v-if="oauthApproval.active && status.authenticated"
v-model:settings="oauthSettings"
:approval="oauthApproval"
@approve="approveOAuth"
/>
<AuthGate
v-else-if="showAuthGate && !status.authenticated"
v-model:setup-password="setupPassword"
:status="status"
:auth-busy="authBusy"
@login="loginWithPasskey"
@setup="setupOwnerPasskey"
/>
<template v-else>
<PortfolioView
v-if="activeTab === 'portfolio'"
:show-portfolio-spinner="showPortfolioSpinner"
:total-asset-value="totalAssetValue"
:buying-power="buyingPower"
:holdings-market-value="holdingsMarketValue"
:total-profit-loss="totalProfitLoss"
:total-profit-loss-rate="totalProfitLossRate"
:stock-asset-ratio="stockAssetRatio"
:cash-asset-ratio="cashAssetRatio"
:positions="positions"
:recent-orders="recentOrders"
:data-loading="dataLoading"
:connected="connected"
:order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice"
@connect="connect"
@open-position="(code) => openTradeForPosition(code, () => void navigate('trade'))"
/>
<TradeView
v-if="activeTab === 'trade'"
v-model:trade-side="tradeSide"
v-model:order-kind="orderKind"
v-model:cash-order-account-type="cashOrderAccountType"
v-model:cash-order-market="cashOrderMarket"
v-model:cash-order-price-condition="cashOrderPriceCondition"
v-model:cash-order-term="cashOrderTerm"
v-model:cash-order-date-input="cashOrderDateInput"
v-model:cash-order-method="cashOrderMethod"
v-model:cash-order-trigger-zone="cashOrderTriggerZone"
v-model:cash-order-trigger-price-input="cashOrderTriggerPriceInput"
v-model:cash-order-secondary-price-condition="cashOrderSecondaryPriceCondition"
v-model:cash-order-secondary-price-input="cashOrderSecondaryPriceInput"
v-model:quantity-input="quantityInput"
v-model:price-input="priceInput"
v-model:chart-mode="chartMode"
:viewed-stocks="viewedStocks"
:selected-stock="selectedStock"
:selected-position="selectedPosition"
:connected="connected"
:order-quantity="orderQuantity"
:estimated-amount="estimatedAmount"
:can-request-cash-estimate="canRequestCashEstimate"
:can-place-cash-order="canPlaceCashOrder"
:realtime-price-points="chartPricePoints"
:price-polling="pricePolling"
:box-plot-style="boxPlotStyle"
:has-quote="hasQuote"
@open-search="showSearch = true"
@select-stock="selectStock"
@download-csv="downloadCsv"
@estimate="estimateCashOrder"
@confirm-order="askPlaceOrder"
/>
<HistoryView
v-if="activeTab === 'history'"
:orders="orders"
:connected="connected"
:data-loading="dataLoading"
:order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice"
@refresh="loadTradingData"
@cancel="cancelOrder"
/>
<SettingsView
v-if="activeTab === 'settings'"
v-model:api-key-label="apiKeyLabel"
v-model:new-api-key-settings="newApiKeySettings"
v-model:new-api-token="newApiToken"
v-model:sbi-label="sbiLabel"
v-model:sbi-credential-json="sbiCredentialJson"
v-model:trade-password="tradePassword"
v-model:sbi-device-id="sbiDeviceId"
v-model:selected-passkey-id="selectedPasskeyId"
:api-keys="apiKeys"
:sbi-passkeys="sbiPasskeys"
@add-api-key="addApiKey"
@refresh="refresh"
@save-api-key-settings="saveApiKeySettings"
@revoke-api-key="revokeAndRefresh"
@add-sbi-passkey="addSbiPasskeyAndConnect"
@connect="connect"
@remove-sbi-passkey="removeSbiPasskey"
/>
</template>
</section>
<AnimatePresence>
<SearchDialog
v-if="showSearch"
key="search-dialog"
v-model:search-query="searchQuery"
v-model:country-filter="countryFilter"
v-model:market-filter="marketFilter"
:stocks="filteredStocks"
:selected-stock-code="selectedStockCode"
:countries="countries"
:markets="markets"
:loading="searchLoading"
@close="showSearch = false"
@select="(stock) => openTradeForStock(stock, () => void navigate('trade'))"
/>
</AnimatePresence>
<OrderDialogs
:estimate="lastCashEstimate"
:show-estimate="showEstimateDialog"
:show-order="showOrderDialog"
:stock-name="selectedStock.name"
:side="tradeSide"
:kind="orderKind"
:account-type="cashOrderAccountType"
:market="cashOrderMarket"
:price-condition="cashOrderPriceCondition"
:price="cashOrderPrimaryRequiresPrice ? orderPrice : 0"
:order-term="cashOrderTerm"
:order-date="cashOrderDateInput"
:order-method="cashOrderMethod"
:trigger-zone="cashOrderTriggerZone"
:trigger-price="cashOrderTriggerPrice"
:secondary-price-condition="cashOrderSecondaryPriceCondition"
:secondary-price="cashOrderSecondaryPrice"
:quantity="orderQuantity"
:amount="estimatedAmount"
@close-estimate="showEstimateDialog = false"
@proceed="askPlaceOrder"
@close-order="showOrderDialog = false"
@place="placeCashOrder"
/>
</main>
</template>
+112
View File
@@ -0,0 +1,112 @@
export type AuthStatus = {
configured: boolean
authenticated: boolean
}
export type ApiKey = {
id: string
label: string
maxTradesPerHour?: number | null
maxTradesPer6Hours?: number | null
maxTradesPerDay?: number | null
maxOrderPriceJpy?: number | null
maxOrderAmountJpy?: number | null
allowedMethods?: string[] | null
createdAt: string
lastUsedAt?: string | null
revokedAt?: string | null
token?: string
}
export type ApiKeySettings = Pick<
ApiKey,
| 'maxTradesPerHour'
| 'maxTradesPer6Hours'
| 'maxTradesPerDay'
| 'maxOrderPriceJpy'
| 'maxOrderAmountJpy'
| 'allowedMethods'
>
export type SbiPasskey = {
id: string
label: string
createdAt: string
updatedAt: string
}
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetch(`/api${path}`, {
credentials: 'include',
headers: {
'content-type': 'application/json',
...init?.headers,
},
...init,
})
if (!response.ok) throw new Error((await response.text()) || response.statusText)
return response.json() as Promise<T>
}
export const getStatus = () => request<AuthStatus>('/auth/status')
export const createSetupOptions = (password: string) =>
request<{ options: unknown; challengeId: string }>('/auth/setup/options', {
method: 'POST',
body: JSON.stringify({ password }),
})
export const verifySetup = (challengeId: string, response: unknown) =>
request<{ ok: true }>('/auth/setup/verify', {
method: 'POST',
body: JSON.stringify({ challengeId, response }),
})
export const createLoginOptions = () =>
request<{ options: unknown; challengeId: string }>('/auth/login/options', {
method: 'POST',
})
export const verifyLogin = (challengeId: string, response: unknown) =>
request<{ ok: true }>('/auth/login/verify', {
method: 'POST',
body: JSON.stringify({ challengeId, response }),
})
export const createApiKey = (label: string, settings?: ApiKeySettings) =>
request<{ apiKey: ApiKey }>('/admin/api-keys', {
method: 'POST',
body: JSON.stringify({ label, settings }),
})
export const listApiKeys = () => request<{ apiKeys: ApiKey[] }>('/admin/api-keys')
export const updateApiKeySettings = (id: string, settings: ApiKeySettings) =>
request<{ ok: true }>(`/admin/api-keys/${id}/settings`, {
method: 'PATCH',
body: JSON.stringify(settings),
})
export const revokeApiKey = (id: string) =>
request<{ ok: true }>(`/admin/api-keys/${id}`, { method: 'DELETE' })
export const listSbiPasskeys = () => request<{ passkeys: SbiPasskey[] }>('/admin/sbi-passkeys')
export const saveSbiPasskey = (payload: {
label: string
credential: unknown
tradePassword?: string
deviceId?: string
}) =>
request<{ passkey: SbiPasskey }>('/admin/sbi-passkeys', {
method: 'POST',
body: JSON.stringify(payload),
})
export const deleteSbiPasskey = (id: string) =>
request<{ ok: true }>(`/admin/sbi-passkeys/${id}`, { method: 'DELETE' })
export const createRpcSocket = () => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
return new WebSocket(`${protocol}//${location.host}/api/ws`)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

@@ -0,0 +1,185 @@
<script setup lang="ts">
import type { ApiKeySettings } from '../api'
defineProps<{
compact?: boolean
permissionsOpen?: boolean
}>()
const settings = defineModel<ApiKeySettings>({ required: true })
const rpcMethods = [
'session.profile',
'account.profile',
'account.power.buyingPower',
'account.power.collateralRatio',
'account.positions.cash',
'account.positions.cashDetail',
'account.positions.cashForIssue',
'account.positions.margin',
'account.positions.marginDetail',
'account.positions.marginForIssue',
'account.positions.marginSummaryForIssue',
'account.positions.marginDetailsForIssue',
'account.positions.closeableMargin',
'account.positions.deliverableMargin',
'account.profitLoss.unrealized',
'market.issue.search',
'market.issue.suggest',
'market.issue.allowedPrices',
'market.issue.board',
'market.issue.chart',
'market.issue.openOrders',
'market.issue.tradingInfo',
'market.index.major',
'market.overview',
'market.ranking.market',
'market.ranking.sector',
'market.ranking.sbi',
'news.list',
'watchlist.list',
'orders.inquiry.executionsToday',
'orders.inquiry.open',
'orders.cash.estimate',
'orders.cash.place',
'orders.cash.estimateCorrection',
'orders.cash.estimateCorrectionConfirm',
'orders.cash.placeCorrection',
'orders.cash.estimateCancel',
'orders.cash.placeCancel',
'orders.margin.estimateOpen',
'orders.margin.open',
'orders.margin.estimateClose',
'orders.margin.close',
'orders.margin.estimateCloseSummary',
'orders.margin.closeSummary',
'orders.margin.estimateSummary',
'orders.margin.placeSummary',
'orders.margin.estimateActualDelivery',
'orders.margin.actualDelivery',
'orders.ifd.estimate',
'orders.ifd.place',
'orders.ifd.estimateCorrection',
'orders.ifd.placeCorrection',
'orders.ifd.estimateCancel',
'orders.ifd.placeCancel',
'orders.themeInvestment.list',
'orders.themeInvestment.estimate',
'orders.themeInvestment.place',
] as const
const tradingMethods = [
'orders.cash.place',
'orders.cash.placeCorrection',
'orders.cash.placeCancel',
'orders.margin.open',
'orders.margin.close',
'orders.margin.closeSummary',
'orders.margin.placeSummary',
'orders.margin.actualDelivery',
'orders.ifd.place',
'orders.ifd.placeCorrection',
'orders.ifd.placeCancel',
'orders.themeInvestment.place',
] as const
const tradingMethodSet = new Set<string>(tradingMethods)
const readMethods = rpcMethods.filter((method) => !tradingMethodSet.has(method))
const setMethods = (methods: readonly string[] | null) => {
settings.value.allowedMethods = methods ? [...methods] : null
}
const toggleMethod = (method: string) => {
const current = settings.value.allowedMethods ?? [...rpcMethods]
settings.value.allowedMethods = current.includes(method)
? current.filter((candidate) => candidate !== method)
: [...current, method].sort()
}
const methodAllowed = (method: string) => {
return settings.value.allowedMethods == null || settings.value.allowedMethods.includes(method)
}
const ui = {
root: 'grid gap-3',
limitGrid: 'grid grid-cols-3 gap-3',
limitGridCompact: 'grid-cols-5',
label: 'grid gap-2 text-xs font-extrabold text-[#9aa0a9]',
input:
'min-h-12 w-full rounded-[16px] border border-[#4a5058] bg-[#111418] px-4 text-[#e3e3e9] outline-none transition focus:border-[#a8c7fa]',
permissions: 'border-t border-[#33383f] pt-3',
summary: 'cursor-pointer font-black text-[#e3e3e9]',
actions: 'mt-3 flex flex-wrap gap-2',
button:
'min-h-9 rounded-full bg-[#263141] px-4 text-sm font-extrabold text-[#d3e3fd] transition hover:bg-[#303b4d]',
methodGrid: 'mt-3 grid grid-cols-2 gap-2',
methodToggle:
'flex min-h-10 items-center gap-2 rounded-[16px] bg-[#111418] p-3 text-xs text-[#c3c7cf]',
checkbox: 'h-4 min-h-4 w-4',
}
</script>
<template>
<div :class="ui.root">
<div :class="[ui.limitGrid, compact && ui.limitGridCompact]">
<label :class="ui.label">
1時間 取引上限
<input v-model.number="settings.maxTradesPerHour" :class="ui.input" type="number" min="0" />
</label>
<label :class="ui.label">
3時間 取引上限
<input
v-model.number="settings.maxTradesPer6Hours"
:class="ui.input"
type="number"
min="0"
/>
</label>
<label :class="ui.label">
1日 取引上限
<input v-model.number="settings.maxTradesPerDay" :class="ui.input" type="number" min="0" />
</label>
<label :class="ui.label">
1注文 最大価格
<input v-model.number="settings.maxOrderPriceJpy" :class="ui.input" type="number" min="0" />
</label>
<label :class="ui.label">
1注文 最大取引価格
<input
v-model.number="settings.maxOrderAmountJpy"
:class="ui.input"
type="number"
min="0"
/>
</label>
</div>
<details :class="ui.permissions" :open="permissionsOpen">
<summary :class="ui.summary">
権限:
{{
settings.allowedMethods == null ? 'すべて' : `${settings.allowedMethods.length} methods`
}}
</summary>
<div :class="ui.actions">
<button :class="ui.button" type="button" @click="setMethods(null)">すべて</button>
<button :class="ui.button" type="button" @click="setMethods(readMethods)">参照のみ</button>
<button :class="ui.button" type="button" @click="setMethods(tradingMethods)">
取引のみ
</button>
</div>
<div :class="ui.methodGrid">
<label v-for="method in rpcMethods" :key="method" :class="ui.methodToggle">
<input
:class="ui.checkbox"
type="checkbox"
:checked="methodAllowed(method)"
@change="toggleMethod(method)"
/>
<span>{{ method }}</span>
</label>
</div>
</details>
</div>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import { pageMeta } from '../../constants/nav'
import { ui } from '../../styles/ui'
import type { RouteName } from '../../router'
const props = defineProps<{
activeTab: RouteName
}>()
const meta = computed(() => pageMeta[props.activeTab])
</script>
<template>
<header :class="[ui.topbar, activeTab === 'trade' && ui.tradeTopbar]">
<div class="flex items-center gap-3">
<span
class="grid h-11 w-11 place-items-center rounded-2xl bg-[#263141] text-[#d3e3fd]"
aria-hidden="true"
>
<component :is="meta.icon" class="h-5 w-5" :stroke-width="2.4" />
</span>
<div>
<h1 class="text-3xl font-black tracking-normal text-[#e3e3e9]">
{{ meta.title }}
</h1>
</div>
</div>
</header>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { Activity } from 'lucide-vue-next'
import { sidebarItems } from '../../constants/nav'
import { ui } from '../../styles/ui'
import type { RouteName } from '../../router'
defineProps<{
activeTab: RouteName
}>()
const emit = defineEmits<{
navigate: [name: RouteName]
}>()
const items = sidebarItems
</script>
<template>
<aside :class="ui.sidebar">
<div :class="ui.brandMark" aria-label="CSBIE">
<Activity class="h-8 w-8" :stroke-width="2.75" aria-hidden="true" />
</div>
<nav :class="ui.navStack" aria-label="Main">
<button
v-for="item in items"
:key="item.name"
:class="[ui.navButton, activeTab === item.name && ui.navButtonActive]"
type="button"
@click="emit('navigate', item.name)"
>
<span :class="[ui.navIcon, activeTab === item.name && ui.navIconActive]">
<component :is="item.icon" class="h-5 w-5" :stroke-width="2.4" aria-hidden="true" />
</span>
<span>{{ item.label }}</span>
</button>
</nav>
<div class="flex-1"></div>
</aside>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { LoaderCircle } from 'lucide-vue-next'
withDefaults(
defineProps<{
label?: string
size?: 'sm' | 'md' | 'lg'
}>(),
{
label: '読み込み中',
size: 'md',
},
)
const sizeClass = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
}
</script>
<template>
<LoaderCircle
:class="['inline-block animate-spin text-current', sizeClass[size]]"
role="status"
:aria-label="label"
/>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'primary' | 'danger' | 'ghost' | 'plain'
size?: 'sm' | 'md'
type?: 'button' | 'submit' | 'reset'
}>(),
{
variant: 'primary',
size: 'md',
type: 'button',
},
)
const variantClass = {
primary:
'bg-[#a8c7fa] text-[#102033] shadow-sm shadow-black/20 hover:bg-[#d3e3fd] active:bg-[#c2d7f4]',
danger:
'bg-[#ffb4ab] text-[#690005] shadow-sm shadow-black/20 hover:bg-[#ffd8d3] active:bg-[#ffc9c2]',
ghost:
'border border-[#4a5058] bg-transparent text-[#d3e3fd] hover:bg-[#263141] active:bg-[#303b4d]',
plain: 'bg-transparent text-[#c3c7cf] hover:bg-[#22272e] hover:text-[#e3e3e9]',
}
const sizeClass = {
sm: 'min-h-8 px-4 text-xs',
md: 'min-h-10 px-5 text-sm',
}
</script>
<template>
<button
:type="type"
:class="[
'rounded-full font-extrabold outline-none transition disabled:cursor-not-allowed disabled:opacity-50',
'inline-flex items-center justify-center gap-2',
variantClass[variant],
sizeClass[size],
]"
>
<slot />
</button>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
withDefaults(
defineProps<{
padded?: boolean
}>(),
{
padded: true,
},
)
</script>
<template>
<article
:class="[
'rounded-[28px] border border-[#30343a] bg-[#1b1f24] shadow-lg shadow-black/15',
padded && 'p-6',
]"
>
<slot />
</article>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { nextTick, onMounted, useTemplateRef } from 'vue'
const props = withDefaults(
defineProps<{
as?: 'input' | 'textarea' | 'select'
label?: string
modelValue?: string | number
type?: string
placeholder?: string
autocomplete?: string
spellcheck?: boolean
readonly?: boolean
disabled?: boolean
min?: string | number
autofocus?: boolean
}>(),
{
as: 'input',
type: 'text',
placeholder: '',
autocomplete: undefined,
spellcheck: undefined,
readonly: false,
disabled: false,
min: undefined,
autofocus: false,
},
)
const controlRef = useTemplateRef<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
'control',
)
onMounted(() => {
if (!props.autofocus) return
nextTick(() => controlRef.value?.focus())
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const controlClass =
'min-h-12 w-full rounded-[16px] border border-[#4a5058] bg-[#111418] px-4 text-[#e3e3e9] outline-none transition placeholder:text-[#747982] focus:border-[#a8c7fa] disabled:cursor-not-allowed disabled:opacity-60'
</script>
<template>
<label class="grid gap-2 text-xs font-extrabold text-[#9aa0a9]">
<span v-if="label">{{ label }}</span>
<textarea
v-if="as === 'textarea'"
ref="control"
:class="[controlClass, 'min-h-32 resize-y py-3']"
:value="modelValue"
:placeholder="placeholder"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
:readonly="readonly"
:disabled="disabled"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
></textarea>
<select
v-else-if="as === 'select'"
ref="control"
:class="controlClass"
:value="modelValue"
:disabled="disabled"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<slot />
</select>
<input
v-else
ref="control"
:class="controlClass"
:value="modelValue"
:type="type"
:min="min"
:placeholder="placeholder"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
:readonly="readonly"
:disabled="disabled"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</label>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{
title: string
eyebrow?: string
}>()
</script>
<template>
<div class="fixed inset-0 z-20 grid place-items-center bg-[#101418]/75 backdrop-blur-sm">
<section
class="grid w-[28rem] gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-2xl shadow-black/35"
>
<p v-if="eyebrow" class="text-xs font-black uppercase text-[#9aa0a9]">{{ eyebrow }}</p>
<h2 class="text-lg font-bold text-[#e3e3e9]">{{ title }}</h2>
<slot />
</section>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts" generic="T extends string">
defineProps<{
modelValue: T
options: Array<{ label: string; value: T; tone?: 'buy' | 'sell' }>
}>()
const emit = defineEmits<{
'update:modelValue': [value: T]
}>()
</script>
<template>
<div
class="grid min-h-12 grid-cols-2 gap-1 rounded-full bg-[#111418] p-1 outline outline-1 outline-[#33383f]"
>
<button
v-for="option in options"
:key="option.value"
type="button"
:class="[
'min-h-10 rounded-full bg-transparent text-sm font-bold text-[#9aa0a9] transition hover:bg-[#22272e]',
modelValue === option.value && option.tone === 'buy' && '!bg-[#40dba2] !text-[#003824]',
modelValue === option.value && option.tone === 'sell' && '!bg-[#ffb4ab] !text-[#690005]',
modelValue === option.value && !option.tone && '!bg-[#263141] !text-[#d3e3fd]',
]"
@click="emit('update:modelValue', option.value)"
>
{{ option.label }}
</button>
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
import { ArrowLeftRight, History, Settings, WalletCards } from 'lucide-vue-next'
import type { Component } from 'vue'
import type { RouteName } from '../router'
export type PageMeta = {
title: string
icon: Component
}
export const pageMeta: Record<RouteName, PageMeta> = {
portfolio: { title: 'ポートフォリオ', icon: WalletCards },
trade: { title: '取引', icon: ArrowLeftRight },
history: { title: '取引履歴', icon: History },
settings: { title: '設定', icon: Settings },
}
export const sidebarItems: Array<{ name: RouteName; label: string; icon: Component }> = [
{ name: 'portfolio', label: '資産一覧', icon: pageMeta.portfolio.icon },
{ name: 'trade', label: '取引', icon: pageMeta.trade.icon },
{ name: 'settings', label: '設定', icon: pageMeta.settings.icon },
]
+85
View File
@@ -0,0 +1,85 @@
import type {
CashOrderAccountType,
CashOrderMarket,
CashOrderMethod,
CashOrderPriceCondition,
CashOrderTerm,
CashOrderTriggerZone,
OrderKind,
TradeSide,
} from '../types/trading'
export const tradeSideOptions: Array<{ label: string; value: TradeSide; tone: 'buy' | 'sell' }> = [
{ label: '購入', value: 'buy', tone: 'buy' },
{ label: '売却', value: 'sell', tone: 'sell' },
]
export const orderKindOptions: Array<{ label: string; value: OrderKind }> = [
{ label: '通常単元', value: 'standard' },
{ label: 'S株', value: 's' },
]
export const cashOrderAccountTypeOptions: Array<{
label: string
value: CashOrderAccountType
}> = [
{ label: '特定', value: 'specific' },
{ label: '一般', value: 'general' },
]
export const cashOrderMarketOptions: Array<{
label: string
value: CashOrderMarket
}> = [
{ label: '自動', value: 'auto' },
{ label: '東証', value: 'TKY' },
{ label: 'SOR', value: 'SOR' },
{ label: 'PTS', value: 'PTS' },
{ label: 'PTS(X)', value: 'PTX' },
]
export const sKabuOrderMarketOptions: Array<{
label: string
value: CashOrderMarket
}> = [{ label: 'S株', value: 'STK' }]
export const cashOrderPriceConditionOptions: Array<{
label: string
value: CashOrderPriceCondition
}> = [
{ label: '成行', value: 'market' },
{ label: '指値', value: 'limit' },
{ label: '寄成', value: 'marketAtOpen' },
{ label: '寄指', value: 'limitAtOpen' },
{ label: '引成', value: 'marketAtClose' },
{ label: '引指', value: 'limitAtClose' },
{ label: 'IOC成', value: 'marketIoc' },
{ label: 'IOC指', value: 'limitIoc' },
{ label: '不成', value: 'funari' },
]
export const cashOrderTermOptions: Array<{
label: string
value: CashOrderTerm
}> = [
{ label: '当日中', value: 'day' },
{ label: '今週中', value: 'week' },
{ label: '日付指定', value: 'date' },
]
export const cashOrderMethodOptions: Array<{
label: string
value: CashOrderMethod
}> = [
{ label: '通常', value: 'normal' },
{ label: '逆指値', value: 'stop' },
{ label: 'OCO', value: 'oco' },
]
export const cashOrderTriggerZoneOptions: Array<{
label: string
value: CashOrderTriggerZone
}> = [
{ label: '以上', value: 'above' },
{ label: '以下', value: 'below' },
]
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { LogIn, UserPlus } from 'lucide-vue-next'
import type { AuthStatus } from '../../api'
import { ui } from '../../styles/ui'
defineProps<{
status: AuthStatus
authBusy: boolean
}>()
const setupPassword = defineModel<string>('setupPassword', { required: true })
const emit = defineEmits<{
login: []
setup: []
}>()
</script>
<template>
<section :class="ui.authPanel">
<article :class="[ui.panel, ui.loginPanel]">
<div>
<p :class="ui.eyebrow">{{ status.configured ? 'Login' : 'Initial setup' }}</p>
<h2>{{ status.configured ? 'パスキーでログイン' : '初期セットアップ' }}</h2>
</div>
<label v-if="!status.configured" :class="ui.label">
セットアップパスワード
<input
v-model="setupPassword"
:class="ui.input"
type="password"
autocomplete="current-password"
/>
</label>
<button
v-if="status.configured"
:class="ui.primaryButton"
type="button"
:disabled="authBusy"
@click="emit('login')"
>
<LogIn class="h-4 w-4" aria-hidden="true" />
ログイン
</button>
<button
v-else
:class="ui.primaryButton"
type="button"
:disabled="authBusy"
@click="emit('setup')"
>
<UserPlus class="h-4 w-4" aria-hidden="true" />
登録
</button>
</article>
</section>
</template>
@@ -0,0 +1,10 @@
import type { ApiKeySettings } from '../../api'
export const defaultApiKeyPolicy = (): ApiKeySettings => ({
maxTradesPerHour: 10,
maxTradesPer6Hours: 30,
maxTradesPerDay: 80,
maxOrderPriceJpy: null,
maxOrderAmountJpy: null,
allowedMethods: null,
})
@@ -0,0 +1,131 @@
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
import { ref } from 'vue'
import {
createApiKey,
createLoginOptions,
createSetupOptions,
deleteSbiPasskey,
getStatus,
listApiKeys,
listSbiPasskeys,
saveSbiPasskey,
updateApiKeySettings,
verifyLogin,
verifySetup,
type ApiKey,
type AuthStatus,
type SbiPasskey,
} from '../../api'
import { defaultApiKeyPolicy } from './api-key-policy'
export const useAuthAdmin = () => {
const status = ref<AuthStatus>({ configured: false, authenticated: false })
const apiKeys = ref<ApiKey[]>([])
const sbiPasskeys = ref<SbiPasskey[]>([])
const selectedPasskeyId = ref('')
const setupPassword = ref('')
const authBusy = ref(false)
const apiKeyLabel = ref('Fine-grained API key')
const newApiKeySettings = ref(defaultApiKeyPolicy())
const newApiToken = ref('')
const sbiLabel = ref('Main profile')
const sbiCredentialJson = ref('')
const tradePassword = ref('')
const sbiDeviceId = ref('')
const refresh = async (options?: { autoConnect?: boolean; connect?: () => void }) => {
status.value = await getStatus()
if (status.value.authenticated) {
apiKeys.value = (await listApiKeys()).apiKeys
sbiPasskeys.value = (await listSbiPasskeys()).passkeys
selectedPasskeyId.value ||= sbiPasskeys.value[0]?.id ?? ''
if (options?.autoConnect && selectedPasskeyId.value) options.connect?.()
}
}
const addApiKey = async () => {
const { apiKey } = await createApiKey(apiKeyLabel.value, newApiKeySettings.value)
newApiToken.value = apiKey.token ?? ''
await refresh()
}
const saveApiKeySettings = async (key: ApiKey) => {
await updateApiKeySettings(key.id, {
maxTradesPerHour: key.maxTradesPerHour,
maxTradesPer6Hours: key.maxTradesPer6Hours,
maxTradesPerDay: key.maxTradesPerDay,
maxOrderPriceJpy: key.maxOrderPriceJpy,
maxOrderAmountJpy: key.maxOrderAmountJpy,
allowedMethods: key.allowedMethods,
})
await refresh()
}
const setupOwnerPasskey = async () => {
authBusy.value = true
try {
const { options, challengeId } = await createSetupOptions(setupPassword.value)
const response = await startRegistration({ optionsJSON: options as never })
await verifySetup(challengeId, response)
setupPassword.value = ''
await refresh()
} finally {
authBusy.value = false
}
}
const loginWithPasskey = async () => {
authBusy.value = true
try {
const { options, challengeId } = await createLoginOptions()
const response = await startAuthentication({ optionsJSON: options as never })
await verifyLogin(challengeId, response)
await refresh()
} finally {
authBusy.value = false
}
}
const addSbiPasskey = async () => {
const { passkey } = await saveSbiPasskey({
label: sbiLabel.value,
credential: JSON.parse(sbiCredentialJson.value),
tradePassword: tradePassword.value || undefined,
deviceId: sbiDeviceId.value || undefined,
})
selectedPasskeyId.value = passkey.id
sbiCredentialJson.value = ''
tradePassword.value = ''
sbiDeviceId.value = ''
await refresh()
}
const removeSbiPasskey = async (id: string) => {
await deleteSbiPasskey(id)
if (selectedPasskeyId.value === id) selectedPasskeyId.value = ''
await refresh()
}
return {
status,
apiKeys,
sbiPasskeys,
selectedPasskeyId,
setupPassword,
authBusy,
apiKeyLabel,
newApiKeySettings,
newApiToken,
sbiLabel,
sbiCredentialJson,
tradePassword,
sbiDeviceId,
refresh,
addApiKey,
saveApiKeySettings,
setupOwnerPasskey,
loginWithPasskey,
addSbiPasskey,
removeSbiPasskey,
}
}
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { Ban, RefreshCw } from 'lucide-vue-next'
import Spinner from '../../components/ui/Spinner.vue'
import { ui } from '../../styles/ui'
import type { OrderRow } from '../../types/trading'
import { orderAmountText, orderQuantityText } from '../trading/trading-data'
defineProps<{
orders: OrderRow[]
connected: boolean
dataLoading: boolean
orderHistoryLoaded: boolean
orderHistoryNotice: string
}>()
const emit = defineEmits<{
refresh: []
cancel: [order: OrderRow]
}>()
</script>
<template>
<section :class="ui.panel">
<div :class="ui.panelHead">
<h2>取引履歴</h2>
<button
:class="ui.ghostButton"
type="button"
:disabled="!connected || dataLoading"
@click="emit('refresh')"
>
<RefreshCw class="h-4 w-4" aria-hidden="true" />
履歴を更新
</button>
</div>
<div :class="ui.list">
<div v-for="order in orders" :key="order.id" :class="ui.orderRow">
<span>
<strong>{{ order.stock }}</strong>
<small>{{ order.date }} / {{ order.kind === 's' ? 'S株' : '通常単元' }}</small>
</span>
<span :class="order.side === 'buy' ? ui.positive : ui.negative">
{{ order.side === 'buy' ? '購入' : '売却' }}
</span>
<span>{{ orderQuantityText(order) }}</span>
<span>{{ orderAmountText(order) }}</span>
<span :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]">
{{ order.status }}
</span>
<button
v-if="order.status === '注文中'"
:class="ui.ghostButton"
type="button"
@click="emit('cancel', order)"
>
<Ban class="h-4 w-4" aria-hidden="true" />
取消
</button>
</div>
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
<Spinner />
</div>
<p v-else-if="!orders.length" :class="[ui.muted, 'py-8 text-center']">
{{
orderHistoryLoaded
? orderHistoryNotice
? `SBI SDK は取引履歴なしを返しました (${orderHistoryNotice})`
: '該当する注文履歴はありません'
: '注文履歴はまだ取得されていません'
}}
</p>
</div>
</section>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ShieldCheck } from 'lucide-vue-next'
import type { ApiKeySettings } from '../../api'
import ApiKeyPolicyEditor from '../../components/ApiKeyPolicyEditor.vue'
import { ui } from '../../styles/ui'
import type { OAuthApprovalState } from './useOAuthApproval'
defineProps<{
approval: OAuthApprovalState
}>()
const settings = defineModel<ApiKeySettings>('settings', { required: true })
const emit = defineEmits<{
approve: []
}>()
</script>
<template>
<section :class="ui.authPanel">
<article :class="ui.panel">
<div :class="ui.panelHead">
<div>
<p :class="ui.eyebrow">OAuth approval</p>
<h2>{{ approval.clientName }}</h2>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('approve')">
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
承認
</button>
</div>
<label :class="ui.label">
Redirect URI
<input v-model="approval.redirectUri" :class="ui.input" readonly />
</label>
<ApiKeyPolicyEditor v-model="settings" permissions-open />
</article>
</section>
</template>
@@ -0,0 +1,78 @@
import { ref } from 'vue'
import type { ApiKeySettings } from '../../api'
import { defaultApiKeyPolicy } from '../auth/api-key-policy'
export type OAuthApprovalState = {
active: boolean
clientId: string
clientName: string
redirectUri: string
codeChallenge: string
scope: string
state: string
resource: string
}
export const useOAuthApproval = () => {
const oauthApproval = ref<OAuthApprovalState>({
active: false,
clientId: '',
clientName: 'MCP client',
redirectUri: '',
codeChallenge: '',
scope: '',
state: '',
resource: '',
})
const oauthSettings = ref<ApiKeySettings>(defaultApiKeyPolicy())
const loadOAuthApproval = async () => {
const url = new URL(location.href)
if (url.pathname !== '/oauth/authorize') return
oauthApproval.value = {
active: true,
clientId: url.searchParams.get('client_id') ?? '',
clientName: url.searchParams.get('client_id') ?? 'MCP client',
redirectUri: url.searchParams.get('redirect_uri') ?? '',
codeChallenge: url.searchParams.get('code_challenge') ?? '',
scope: url.searchParams.get('scope') ?? '',
state: url.searchParams.get('state') ?? '',
resource: url.searchParams.get('resource') ?? '',
}
if (!oauthApproval.value.clientId) return
const response = await fetch(`/api/oauth/client/${oauthApproval.value.clientId}`, {
credentials: 'include',
})
if (!response.ok) return
const { client } = (await response.json()) as { client?: { client_name?: string } }
oauthApproval.value.clientName = client?.client_name ?? oauthApproval.value.clientName
}
const approveOAuth = async () => {
const response = await fetch('/api/oauth/approve', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
clientId: oauthApproval.value.clientId,
redirectUri: oauthApproval.value.redirectUri,
codeChallenge: oauthApproval.value.codeChallenge,
scope: oauthApproval.value.scope,
state: oauthApproval.value.state,
resource: oauthApproval.value.resource || undefined,
settings: oauthSettings.value,
}),
})
if (!response.ok) throw new Error(await response.text())
const { redirectTo } = (await response.json()) as { redirectTo: string }
location.href = redirectTo
}
return {
oauthApproval,
oauthSettings,
loadOAuthApproval,
approveOAuth,
}
}
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { ArrowLeft, Send, ShieldCheck } from 'lucide-vue-next'
import UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
import type {
CashOrderAccountType,
CashOrderMarket,
CashOrderMethod,
CashOrderPriceCondition,
CashOrderTerm,
CashOrderTriggerZone,
OrderKind,
OrderPreview,
TradeSide,
} from '../../types/trading'
import { currency } from '../../utils/format'
defineProps<{
estimate: OrderPreview | null
showEstimate: boolean
showOrder: boolean
stockName: string
side: TradeSide
kind: OrderKind
accountType: CashOrderAccountType
market: CashOrderMarket
priceCondition: CashOrderPriceCondition
price: number
orderTerm: CashOrderTerm
orderDate: string
orderMethod: CashOrderMethod
triggerZone: CashOrderTriggerZone
triggerPrice: number
secondaryPriceCondition: CashOrderPriceCondition
secondaryPrice: number
quantity: number
amount: number
}>()
const emit = defineEmits<{
closeEstimate: []
proceed: []
closeOrder: []
place: []
}>()
const priceConditionLabel = (condition: CashOrderPriceCondition) =>
({
limit: '指値',
limitAtOpen: '寄指',
limitAtClose: '引指',
limitIoc: 'IOC指',
market: '成行',
marketAtOpen: '寄成',
marketAtClose: '引成',
marketIoc: 'IOC成',
funari: '不成',
})[condition]
const orderTermLabel = (term: CashOrderTerm, date: string) =>
term === 'day' ? '当日中' : term === 'week' ? '今週中' : date
const orderMethodLabel = (method: CashOrderMethod) =>
method === 'normal' ? '通常' : method === 'stop' ? '逆指値' : 'OCO'
const triggerZoneLabel = (zone: CashOrderTriggerZone) => (zone === 'above' ? '以上' : '以下')
</script>
<template>
<UiModal v-if="showEstimate && estimate" eyebrow="見積" :title="stockName">
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>売買</dt>
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>数量</dt>
<dd>{{ quantity }}株</dd>
</div>
<div :class="ui.confirmRow">
<dt>預り区分</dt>
<dd>{{ accountType === 'specific' ? '特定' : '一般' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>注文市場</dt>
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
</div>
<template v-if="kind !== 's'">
<div :class="ui.confirmRow">
<dt>執行条件</dt>
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
</div>
<div v-if="price > 0" :class="ui.confirmRow">
<dt>注文価格</dt>
<dd>{{ currency(price) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>有効期限</dt>
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>特殊注文</dt>
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
</div>
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
<dt>逆指値</dt>
<dd>{{ currency(triggerPrice) }} {{ triggerZoneLabel(triggerZone) }}</dd>
</div>
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
<dt>OCO条件</dt>
<dd>
{{ priceConditionLabel(secondaryPriceCondition) }}
<template v-if="secondaryPrice > 0"> {{ currency(secondaryPrice) }}</template>
</dd>
</div>
</template>
<div :class="ui.confirmRow">
<dt>概算</dt>
<dd>{{ currency(amount) }}</dd>
</div>
</dl>
<p :class="ui.dialogNote">
{{ estimate.message ?? estimate.warnings.join(' ') }}
</p>
<div :class="ui.actions">
<UiButton variant="ghost" @click="emit('closeEstimate')">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
戻る
</UiButton>
<UiButton @click="emit('proceed')">
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
注文確認へ
</UiButton>
</div>
</UiModal>
<UiModal v-if="showOrder" eyebrow="注文確認" :title="stockName">
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>区分</dt>
<dd>{{ kind === 's' ? 'S株' : '通常単元' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>売買</dt>
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>数量</dt>
<dd>{{ quantity }}株</dd>
</div>
<div :class="ui.confirmRow">
<dt>預り区分</dt>
<dd>{{ accountType === 'specific' ? '特定' : '一般' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>注文市場</dt>
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
</div>
<template v-if="kind !== 's'">
<div :class="ui.confirmRow">
<dt>執行条件</dt>
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
</div>
<div v-if="price > 0" :class="ui.confirmRow">
<dt>注文価格</dt>
<dd>{{ currency(price) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>有効期限</dt>
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>特殊注文</dt>
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
</div>
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
<dt>逆指値</dt>
<dd>{{ currency(triggerPrice) }} {{ triggerZoneLabel(triggerZone) }}</dd>
</div>
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
<dt>OCO条件</dt>
<dd>
{{ priceConditionLabel(secondaryPriceCondition) }}
<template v-if="secondaryPrice > 0"> {{ currency(secondaryPrice) }}</template>
</dd>
</div>
</template>
<div :class="ui.confirmRow">
<dt>概算</dt>
<dd>{{ currency(amount) }}</dd>
</div>
</dl>
<div :class="ui.actions">
<UiButton variant="ghost" @click="emit('closeOrder')">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
戻る
</UiButton>
<UiButton variant="danger" @click="emit('place')">
<Send class="h-4 w-4" aria-hidden="true" />
発注
</UiButton>
</div>
</UiModal>
</template>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { Plug } from 'lucide-vue-next'
import Spinner from '../../components/ui/Spinner.vue'
import { ui } from '../../styles/ui'
import type { OrderRow, Position } from '../../types/trading'
import { currency, signedCurrency, signedPercent } from '../../utils/format'
import { orderAmountText, orderQuantityText } from '../trading/trading-data'
defineProps<{
showPortfolioSpinner: boolean
totalAssetValue: number
buyingPower: number
holdingsMarketValue: number
totalProfitLoss: number
totalProfitLossRate: number
stockAssetRatio: number
cashAssetRatio: number
positions: Position[]
recentOrders: OrderRow[]
dataLoading: boolean
connected: boolean
orderHistoryLoaded: boolean
orderHistoryNotice: string
}>()
const emit = defineEmits<{
connect: []
openPosition: [code: string]
}>()
</script>
<template>
<section :class="ui.dashboardGrid">
<article :class="ui.assetOverviewPanel">
<div :class="ui.assetOverviewHead">
<span :class="ui.metricLabel">総資産価値</span>
<strong :class="ui.metricValue">
<template v-if="!showPortfolioSpinner">{{ currency(totalAssetValue) }}</template>
<Spinner v-else size="lg" />
</strong>
<small :class="ui.assetOverviewSubtext">
余力:
<template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template>
<Spinner v-else size="sm" />
</small>
</div>
<div :class="ui.assetBreakdownPanel">
<span :class="ui.assetBreakdownTitle">内訳</span>
<i :class="ui.assetBreakdownBar" aria-hidden="true">
<b :class="ui.assetBreakdownStocks" :style="{ width: `${stockAssetRatio}%` }"></b>
<b :class="ui.assetBreakdownCash" :style="{ width: `${cashAssetRatio}%` }"></b>
</i>
<div :class="ui.assetBreakdownRows">
<div :class="ui.assetBreakdownRow">
<span :class="ui.assetBreakdownLabel">
<i :class="[ui.assetBreakdownSwatch, ui.assetBreakdownSwatchStocks]"></i>
株式
</span>
<span :class="ui.assetBreakdownMeta">
<strong :class="ui.assetBreakdownAmount">
<template v-if="!showPortfolioSpinner">{{
currency(holdingsMarketValue)
}}</template>
<Spinner v-else size="sm" />
</strong>
<small :class="ui.assetBreakdownRatio">
<template v-if="!showPortfolioSpinner">{{ stockAssetRatio.toFixed(1) }}%</template>
<Spinner v-else size="sm" />
</small>
</span>
</div>
<div :class="ui.assetBreakdownRow">
<span :class="ui.assetBreakdownLabel">
<i :class="[ui.assetBreakdownSwatch, ui.assetBreakdownSwatchCash]"></i>
余力
</span>
<span :class="ui.assetBreakdownMeta">
<strong :class="ui.assetBreakdownAmount">
<template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template>
<Spinner v-else size="sm" />
</strong>
<small :class="ui.assetBreakdownRatio">
<template v-if="!showPortfolioSpinner">{{ cashAssetRatio.toFixed(1) }}%</template>
<Spinner v-else size="sm" />
</small>
</span>
</div>
</div>
</div>
</article>
<article :class="ui.metricPanel">
<span :class="ui.metricLabel">通算評価損益</span>
<strong :class="[ui.metricValue, totalProfitLoss >= 0 ? ui.positive : ui.negative]">
<template v-if="!showPortfolioSpinner">
{{ signedCurrency(totalProfitLoss) }}
<small> · {{ signedPercent(totalProfitLossRate) }}</small>
</template>
<Spinner v-else size="lg" />
</strong>
</article>
<article :class="ui.holdingsPanel">
<div :class="ui.panelHead">
<h2>保有銘柄</h2>
<button
:class="ui.ghostButton"
type="button"
:disabled="dataLoading"
@click="emit('connect')"
>
<Plug class="h-4 w-4" aria-hidden="true" />
{{ connected ? '再取得' : '接続' }}
</button>
</div>
<div :class="ui.holdingsBody">
<div :class="ui.holdingsHead">
<span>銘柄</span>
<span>タイプ</span>
<span>数量</span>
<span>評価額</span>
<span>評価損益</span>
</div>
<div v-if="positions.length" :class="ui.holdingsRows">
<button
v-for="position in positions"
:key="position.code"
:class="ui.holdingRow"
type="button"
@click="emit('openPosition', position.code)"
>
<span class="grid gap-1">
<strong>{{ position.name }}</strong>
<small>{{ position.code }}</small>
</span>
<b :class="ui.typePill">{{
position.type ?? (position.quantity >= 100 ? '単元' : 'S株')
}}</b>
<span>{{ position.quantity }}</span>
<span :class="ui.muted">{{ currency(position.marketValue) }}</span>
<span
class="grid justify-items-end gap-0.5"
:class="position.profitLoss >= 0 ? ui.positive : ui.negative"
>
<strong>{{ signedCurrency(position.profitLoss) }}</strong>
<small>{{ signedPercent(position.profitLossRate) }}</small>
</span>
</button>
</div>
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
<Spinner />
</div>
<p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると保有銘柄を表示します</p>
</div>
</article>
<article :class="ui.portfolioHistory">
<h2>取引履歴</h2>
<div :class="ui.historyList">
<div v-if="recentOrders.length" :class="ui.historyRows">
<div v-for="order in recentOrders" :key="order.id" :class="ui.miniOrder">
<span class="grid gap-1">
<strong>{{ order.stock }}</strong>
<small
>{{ order.side === 'buy' ? '買付' : '売却' }}({{
order.kind === 's' ? 'S株' : '単元'
}}) ・ {{ orderQuantityText(order) }}</small
>
</span>
<span class="grid justify-items-end gap-1">
<small>{{ order.date.slice(5, 10) }}</small>
<strong>{{ orderAmountText(order) }}</strong>
</span>
</div>
</div>
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
<Spinner />
</div>
<p v-else :class="[ui.muted, ui.emptyState]">
{{
orderHistoryLoaded
? orderHistoryNotice
? `SBI SDK は取引履歴なしを返しました (${orderHistoryNotice})`
: '該当する取引履歴はありません'
: '取引履歴はまだ取得されていません'
}}
</p>
</div>
</article>
</section>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { motion } from 'motion-v'
import Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue'
import UiField from '../../components/ui/UiField.vue'
import searchEmptyImage from '../../assets/search-empty.png'
import { ui } from '../../styles/ui'
import type { Stock } from '../../types/trading'
import { currency } from '../../utils/format'
const overlayTransition = { duration: 0.2, ease: 'easeOut' } as const
const sheetTransition = { type: 'spring', damping: 28, stiffness: 320 } as const
defineProps<{
stocks: Stock[]
selectedStockCode: string
countries: string[]
markets: string[]
loading?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery', { required: true })
const countryFilter = defineModel<string>('countryFilter', { required: true })
const marketFilter = defineModel<string>('marketFilter', { required: true })
const emit = defineEmits<{
close: []
select: [stock: Stock]
}>()
</script>
<template>
<motion.div
:class="ui.searchOverlay"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:exit="{ opacity: 0 }"
:transition="overlayTransition"
@click.self="emit('close')"
>
<motion.section
:class="ui.searchSheet"
role="dialog"
aria-modal="true"
aria-label="銘柄検索"
:initial="{ opacity: 0, y: -20, scale: 0.98 }"
:animate="{ opacity: 1, y: 0, scale: 1 }"
:exit="{ opacity: 0, y: -16, scale: 0.98 }"
:transition="sheetTransition"
>
<div :class="ui.searchInputRow">
<UiField v-model="searchQuery" autofocus placeholder="銘柄名、コード、シンボル" />
<UiButton variant="ghost" @click="emit('close')">
<X class="h-4 w-4" aria-hidden="true" />
閉じる
</UiButton>
</div>
<div :class="ui.filterRow">
<UiField v-model="countryFilter" as="select">
<option value="all">すべての国</option>
<option v-for="country in countries" :key="country" :value="country">
{{ country }}
</option>
</UiField>
<UiField v-model="marketFilter" as="select">
<option value="all">すべての市場</option>
<option v-for="marketName in markets" :key="marketName" :value="marketName">
{{ marketName }}
</option>
</UiField>
</div>
<div :class="ui.searchResults">
<div v-if="loading" :class="ui.searchLoading" role="status" aria-live="polite">
<Spinner />
<span>検索中...</span>
</div>
<template v-else>
<div
v-if="!stocks.length"
:class="[ui.muted, 'grid justify-items-center gap-3 py-8 text-center']"
>
<img
:src="searchEmptyImage"
alt=""
class="h-32 w-32 object-contain opacity-75"
loading="lazy"
aria-hidden="true"
/>
<p>ありません</p>
</div>
<template v-else>
<button
v-for="stock in stocks"
:key="stock.code"
:class="[ui.searchResult, selectedStockCode === stock.code && ui.searchResultActive]"
type="button"
:aria-current="selectedStockCode === stock.code ? 'true' : undefined"
@click="emit('select', stock)"
>
<span class="grid gap-1">
<strong>{{ stock.name }}</strong>
<small>{{ stock.code }} / {{ stock.country }} / {{ stock.market }}</small>
</span>
<span>
<template v-if="stock.price > 0">{{ currency(stock.price) }}</template>
<Spinner v-else size="sm" />
</span>
</button>
</template>
</template>
</div>
</motion.section>
</motion.div>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { Ban, KeyRound, Plug, RefreshCw, Save, Trash2 } from 'lucide-vue-next'
import type { ApiKey, ApiKeySettings, SbiPasskey } from '../../api'
import ApiKeyPolicyEditor from '../../components/ApiKeyPolicyEditor.vue'
import { ui } from '../../styles/ui'
defineProps<{
apiKeys: ApiKey[]
sbiPasskeys: SbiPasskey[]
}>()
const apiKeyLabel = defineModel<string>('apiKeyLabel', { required: true })
const newApiKeySettings = defineModel<ApiKeySettings>('newApiKeySettings', { required: true })
const newApiToken = defineModel<string>('newApiToken', { required: true })
const sbiLabel = defineModel<string>('sbiLabel', { required: true })
const sbiCredentialJson = defineModel<string>('sbiCredentialJson', { required: true })
const tradePassword = defineModel<string>('tradePassword', { required: true })
const sbiDeviceId = defineModel<string>('sbiDeviceId', { required: true })
const selectedPasskeyId = defineModel<string>('selectedPasskeyId', { required: true })
const emit = defineEmits<{
addApiKey: []
refresh: []
saveApiKeySettings: [key: ApiKey]
revokeApiKey: [id: string]
addSbiPasskey: []
connect: []
removeSbiPasskey: [id: string]
}>()
</script>
<template>
<section :class="ui.settingsLayout">
<div :class="ui.apiLayout">
<article :class="ui.panel">
<div :class="ui.panelHead">
<div>
<p :class="ui.eyebrow">Fine-grained</p>
<h2>API Key 発行</h2>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('addApiKey')">
<KeyRound class="h-4 w-4" aria-hidden="true" />
発行
</button>
</div>
<label :class="ui.label">
名前
<input v-model="apiKeyLabel" :class="ui.input" placeholder="例: trade-bot-readonly" />
</label>
<ApiKeyPolicyEditor v-model="newApiKeySettings" permissions-open />
<textarea v-if="newApiToken" v-model="newApiToken" :class="ui.input" readonly></textarea>
</article>
<article :class="ui.panel">
<div :class="ui.panelHead">
<h2>発行済みキー</h2>
<button :class="ui.ghostButton" type="button" @click="emit('refresh')">
<RefreshCw class="h-4 w-4" aria-hidden="true" />
更新
</button>
</div>
<div :class="ui.list">
<div v-for="key in apiKeys" :key="key.id" :class="ui.keyRow">
<div :class="ui.row">
<span>{{ key.label }}</span>
<div :class="ui.rowActions">
<button
:class="ui.primaryButton"
type="button"
title="Save settings"
@click="emit('saveApiKeySettings', key)"
>
<Save class="h-4 w-4" aria-hidden="true" />
保存
</button>
<button
:class="ui.dangerButton"
type="button"
title="Revoke"
@click="emit('revokeApiKey', key.id)"
>
<Ban class="h-4 w-4" aria-hidden="true" />
失効
</button>
</div>
</div>
<ApiKeyPolicyEditor
:model-value="key"
compact
@update:model-value="Object.assign(key, $event)"
/>
</div>
</div>
</article>
</div>
<div :class="ui.apiLayout">
<article :class="ui.panel">
<div :class="ui.panelHead">
<div>
<p :class="ui.eyebrow">Profiles</p>
<h2>パスキー設定</h2>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('addSbiPasskey')">
<Save class="h-4 w-4" aria-hidden="true" />
保存
</button>
</div>
<label :class="ui.label">
名前
<input v-model="sbiLabel" :class="ui.input" placeholder="例: 個人メイン" />
</label>
<label :class="ui.label">
パスキー JSON
<textarea
v-model="sbiCredentialJson"
:class="ui.input"
spellcheck="false"
placeholder="{ ... }"
></textarea>
</label>
<div :class="ui.ticketBox">
<label :class="ui.label">
取引パスワード
<input v-model="tradePassword" :class="ui.input" type="password" autocomplete="off" />
</label>
<label :class="ui.label">
デバイスキー
<input v-model="sbiDeviceId" :class="ui.input" autocomplete="off" spellcheck="false" />
</label>
</div>
</article>
<article :class="ui.panel">
<div :class="ui.panelHead">
<h2>プロフィール切り替え</h2>
<button :class="ui.ghostButton" type="button" @click="emit('connect')">
<Plug class="h-4 w-4" aria-hidden="true" />
接続
</button>
</div>
<div :class="ui.list">
<label v-for="passkey in sbiPasskeys" :key="passkey.id" :class="ui.profileRow">
<input
v-model="selectedPasskeyId"
type="radio"
:value="passkey.id"
@change="emit('connect')"
/>
<span>
<strong>{{ passkey.label }}</strong>
<small>{{ new Date(passkey.createdAt).toLocaleString() }}</small>
</span>
<button
:class="ui.dangerButton"
type="button"
title="Delete passkey"
@click.prevent="emit('removeSbiPasskey', passkey.id)"
>
<Trash2 class="h-4 w-4" aria-hidden="true" />
削除
</button>
</label>
</div>
</article>
</div>
</section>
</template>
@@ -0,0 +1,225 @@
<script setup lang="ts">
import Chart from 'chart.js/auto'
import { Minus, Plus, RotateCcw } from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Spinner from '../../components/ui/Spinner.vue'
import type { RealtimePricePoint } from '../../types/trading'
const props = defineProps<{
points: RealtimePricePoint[]
stockName: string
active: boolean
}>()
const canvas = ref<HTMLCanvasElement | null>(null)
const scroller = ref<HTMLDivElement | null>(null)
const zoomScale = ref(1)
let chart: Chart<'line', number[], string> | undefined
let wasScrolledToEnd = true
const minChartWidth = 720
const minZoom = 0.6
const maxZoom = 3
const zoomStep = 0.2
const basePointSpacing = 10
const pointSpacing = computed(() => basePointSpacing * zoomScale.value)
const labelFor = (point: RealtimePricePoint) => {
const date = new Date(point.at)
if (date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0) {
return new Intl.DateTimeFormat('ja-JP', {
month: 'numeric',
day: 'numeric',
}).format(date)
}
return new Intl.DateTimeFormat('ja-JP', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date)
}
const labels = computed(() => props.points.map(labelFor))
const prices = computed(() => props.points.map((point) => point.price))
const chartWidth = computed(() => {
const width = Math.max(minChartWidth, props.points.length * pointSpacing.value + 72)
return `max(100%, ${width}px)`
})
const rememberScrollPosition = () => {
const element = scroller.value
if (!element) return
wasScrolledToEnd =
element.scrollLeft + element.clientWidth >= element.scrollWidth - pointSpacing.value
}
const scrollToEndIfNeeded = async () => {
if (!wasScrolledToEnd) return
await nextTick()
const element = scroller.value
if (!element) return
element.scrollLeft = element.scrollWidth
}
const updateChart = () => {
if (!chart) return
rememberScrollPosition()
chart.data.labels = labels.value
const dataset = chart.data.datasets[0]
if (dataset) dataset.data = prices.value
chart.update('none')
chart.resize()
void scrollToEndIfNeeded()
}
const setZoomScale = (value: number) => {
rememberScrollPosition()
zoomScale.value = Math.min(maxZoom, Math.max(minZoom, value))
}
const zoomOut = () => setZoomScale(zoomScale.value - zoomStep)
const zoomIn = () => setZoomScale(zoomScale.value + zoomStep)
const resetZoom = () => setZoomScale(1)
const setZoomScaleFromInput = (event: Event) => {
const value = Number((event.target as HTMLInputElement).value)
setZoomScale(value)
}
const handleWheel = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault()
setZoomScale(zoomScale.value + (event.deltaY < 0 ? zoomStep : -zoomStep))
}
onMounted(() => {
if (!canvas.value) return
chart = new Chart(canvas.value, {
type: 'line',
data: {
labels: labels.value,
datasets: [
{
data: prices.value,
borderColor: '#40dba2',
backgroundColor: 'rgba(64, 219, 162, 0.14)',
borderWidth: 2,
fill: true,
pointBackgroundColor: '#d3e3fd',
pointBorderColor: '#40dba2',
pointRadius: 2.5,
pointHoverRadius: 4,
tension: 0.25,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { display: false },
tooltip: {
displayColors: false,
callbacks: {
label: (item) => `${(item.parsed.y ?? 0).toLocaleString('ja-JP')}円`,
},
},
},
scales: {
x: {
grid: { color: 'rgba(143, 148, 157, 0.12)' },
ticks: { color: '#8f949d', maxTicksLimit: 5 },
},
y: {
grid: { color: 'rgba(143, 148, 157, 0.12)' },
ticks: {
color: '#8f949d',
callback: (value) =>
typeof value === 'number' ? value.toLocaleString('ja-JP') : value,
},
},
},
},
})
void scrollToEndIfNeeded()
})
watch(() => props.points, updateChart, { deep: true })
watch(chartWidth, () => {
chart?.resize()
void scrollToEndIfNeeded()
})
onBeforeUnmount(() => {
chart?.destroy()
chart = undefined
})
</script>
<template>
<div class="relative h-full w-full">
<div
ref="scroller"
class="h-full w-full overflow-x-auto overflow-y-hidden"
@scroll="rememberScrollPosition"
@wheel="handleWheel"
>
<div class="h-full" :style="{ width: chartWidth }">
<canvas
ref="canvas"
class="h-full w-full"
:aria-label="`${stockName} realtime price chart`"
></canvas>
</div>
</div>
<span
v-if="active && !points.length"
class="absolute inset-0 grid place-items-center text-[#8f949d]"
>
<Spinner />
</span>
<div
class="absolute right-2 top-2 flex items-center gap-1 rounded-md border border-[#2d3440] bg-[#111418]/90 px-1.5 py-1 shadow-sm"
>
<button
class="grid h-6 w-6 place-items-center rounded text-[#d3e3fd] hover:bg-[#1d232b]"
type="button"
aria-label="縮小"
@click="zoomOut"
>
<Minus class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<input
:value="zoomScale"
class="h-6 w-24 accent-[#40dba2]"
type="range"
:min="minZoom"
:max="maxZoom"
:step="zoomStep"
aria-label="グラフ縮尺"
@input="setZoomScaleFromInput"
/>
<button
class="grid h-6 w-6 place-items-center rounded text-[#d3e3fd] hover:bg-[#1d232b]"
type="button"
aria-label="拡大"
@click="zoomIn"
>
<Plus class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
class="grid h-6 w-6 place-items-center rounded text-[#8f949d] hover:bg-[#1d232b] hover:text-[#d3e3fd]"
type="button"
aria-label="縮尺を戻す"
@click="resetZoom"
>
<RotateCcw class="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,546 @@
<script setup lang="ts">
import { ChevronDown, Download, FileCheck2, Search, ShieldCheck } from 'lucide-vue-next'
import { AnimatePresence, LayoutGroup, motion } from 'motion-v'
import { computed, ref } from 'vue'
import Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue'
import UiField from '../../components/ui/UiField.vue'
import UiSegmented from '../../components/ui/UiSegmented.vue'
import {
cashOrderAccountTypeOptions,
cashOrderMarketOptions,
cashOrderMethodOptions,
cashOrderPriceConditionOptions,
cashOrderTermOptions,
cashOrderTriggerZoneOptions,
orderKindOptions,
sKabuOrderMarketOptions,
tradeSideOptions,
} from '../../constants/trade'
import { ui } from '../../styles/ui'
import type {
ChartMode,
CashOrderAccountType,
CashOrderMarket,
CashOrderMethod,
CashOrderPriceCondition,
CashOrderTerm,
CashOrderTriggerZone,
OrderKind,
Position,
RealtimePricePoint,
Stock,
TradeSide,
} from '../../types/trading'
import { currency } from '../../utils/format'
import RealtimePriceChart from './RealtimePriceChart.vue'
type StockInfoTab = 'detail' | 'holding'
defineProps<{
viewedStocks: Stock[]
selectedStock: Stock
selectedPosition?: Position
connected: boolean
orderQuantity: number
estimatedAmount: number
canRequestCashEstimate: boolean
canPlaceCashOrder: boolean
realtimePricePoints: RealtimePricePoint[]
pricePolling: boolean
boxPlotStyle: {
min: string
q1: string
median: string
q3: string
max: string
}
hasQuote: (stock: Stock) => boolean
}>()
const tradeSide = defineModel<TradeSide>('tradeSide', { required: true })
const orderKind = defineModel<OrderKind>('orderKind', { required: true })
const cashOrderAccountType = defineModel<CashOrderAccountType>('cashOrderAccountType', {
required: true,
})
const cashOrderMarket = defineModel<CashOrderMarket>('cashOrderMarket', {
required: true,
})
const cashOrderPriceCondition = defineModel<CashOrderPriceCondition>('cashOrderPriceCondition', {
required: true,
})
const cashOrderTerm = defineModel<CashOrderTerm>('cashOrderTerm', { required: true })
const cashOrderDateInput = defineModel<string>('cashOrderDateInput', { required: true })
const cashOrderMethod = defineModel<CashOrderMethod>('cashOrderMethod', { required: true })
const cashOrderTriggerZone = defineModel<CashOrderTriggerZone>('cashOrderTriggerZone', {
required: true,
})
const cashOrderTriggerPriceInput = defineModel<string>('cashOrderTriggerPriceInput', {
required: true,
})
const cashOrderSecondaryPriceCondition = defineModel<CashOrderPriceCondition>(
'cashOrderSecondaryPriceCondition',
{
required: true,
},
)
const cashOrderSecondaryPriceInput = defineModel<string>('cashOrderSecondaryPriceInput', {
required: true,
})
const quantityInput = defineModel<string>('quantityInput', { required: true })
const priceInput = defineModel<string>('priceInput', { required: true })
const chartMode = defineModel<ChartMode>('chartMode', { required: true })
const activeStockInfoTab = ref<StockInfoTab>('detail')
const advancedOptionsOpen = ref(false)
const orderMarketOptions = computed(() =>
orderKind.value === 's' ? sKabuOrderMarketOptions : cashOrderMarketOptions,
)
const primaryPriceConditionRequiresPrice = computed(() =>
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
cashOrderPriceCondition.value,
),
)
const secondaryPriceConditionRequiresPrice = computed(() =>
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
cashOrderSecondaryPriceCondition.value,
),
)
const tabTransition = { type: 'spring', damping: 28, stiffness: 360 } as const
const tabContentTransition = { duration: 0.18, ease: 'easeOut' } as const
const advancedTransition = { duration: 0.18, ease: 'easeOut' } as const
const emit = defineEmits<{
openSearch: []
selectStock: [stock: Stock]
downloadCsv: []
estimate: []
confirmOrder: []
}>()
</script>
<template>
<section :class="ui.tradeLayout">
<article :class="ui.watchlist">
<button :class="ui.watchSearch" type="button" @click="emit('openSearch')">
<Search class="h-4 w-4" aria-hidden="true" />
Search
</button>
<button
v-for="stock in viewedStocks"
:key="stock.code"
:class="[ui.watchRow, selectedStock.code === stock.code && ui.watchRowActive]"
type="button"
:aria-current="selectedStock.code === stock.code ? 'true' : undefined"
@click="emit('selectStock', stock)"
>
<span class="grid gap-1">
<strong>{{ stock.name }}</strong>
<small>{{ stock.symbol }}</small>
</span>
<span class="grid justify-items-end gap-1">
<strong>
<template v-if="hasQuote(stock)">{{ currency(stock.price) }}</template>
<Spinner v-else size="sm" />
</strong>
<small v-if="hasQuote(stock)" :class="stock.change >= 0 ? ui.positive : ui.negative">
{{ stock.change >= 0 ? '+' : '' }}{{ stock.change }}%
</small>
<Spinner v-else size="sm" />
</span>
</button>
<p v-if="!viewedStocks.length" :class="[ui.muted, 'p-5 text-sm']">
SBIに接続するか検索すると銘柄を表示します
</p>
</article>
<div :class="ui.centerStack">
<article :class="ui.stockPanel">
<div :class="ui.stockTitle">
<div>
<p :class="ui.eyebrow">{{ selectedStock.symbol }}</p>
<h2>{{ selectedStock.name }}</h2>
</div>
<div :class="ui.quoteBox">
<strong>
<template v-if="hasQuote(selectedStock)">{{
currency(selectedStock.price)
}}</template>
<Spinner v-else size="sm" />
</strong>
<small
v-if="hasQuote(selectedStock)"
:class="selectedStock.change >= 0 ? ui.positive : ui.negative"
>
{{ selectedStock.change >= 0 ? '+' : '' }}{{ selectedStock.change }}%
</small>
<Spinner v-else size="sm" />
</div>
</div>
<div :class="ui.periodTabs">
<button :class="[ui.periodButton, ui.periodButtonActive]" type="button">1D</button>
<button :class="ui.periodButton" type="button">1W</button>
<button :class="ui.periodButton" type="button">1M</button>
</div>
<div :class="ui.chartActions">
<div :class="ui.smallTabs">
<button
:class="[ui.smallTab, chartMode === 'line' && ui.smallTabActive]"
type="button"
@click="chartMode = 'line'"
>
推移
</button>
<button
:class="[ui.smallTab, chartMode === 'box' && ui.smallTabActive]"
type="button"
@click="chartMode = 'box'"
>
箱ひげ
</button>
</div>
<button :class="ui.ghostButton" type="button" @click="emit('downloadCsv')">
<Download class="h-4 w-4" aria-hidden="true" />
CSV
</button>
</div>
<div v-if="chartMode === 'line'" :class="ui.chartBox">
<RealtimePriceChart
v-if="hasQuote(selectedStock)"
:points="realtimePricePoints"
:stock-name="selectedStock.name"
:active="pricePolling"
/>
<span v-else class="grid h-full place-items-center text-[#8f949d]">
<Spinner />
</span>
</div>
<div v-else :class="ui.boxplot">
<div :class="ui.boxplotScale">
<span>
<template v-if="hasQuote(selectedStock)">{{
currency(selectedStock.box.min)
}}</template>
<Spinner v-else size="sm" />
</span>
<span>
<template v-if="hasQuote(selectedStock)">{{
currency(selectedStock.box.max)
}}</template>
<Spinner v-else size="sm" />
</span>
</div>
<div v-if="hasQuote(selectedStock)" :class="ui.boxplotTrack">
<i
:class="ui.whisker"
:style="{
left: boxPlotStyle.min,
width: `calc(${boxPlotStyle.max} - ${boxPlotStyle.min})`,
}"
></i>
<i
:class="ui.box"
:style="{
left: boxPlotStyle.q1,
width: `calc(${boxPlotStyle.q3} - ${boxPlotStyle.q1})`,
}"
></i>
<i :class="ui.median" :style="{ left: boxPlotStyle.median }"></i>
</div>
<div v-else class="grid min-h-20 place-items-center text-[#8f949d]">
<Spinner />
</div>
</div>
</article>
<LayoutGroup id="stock-info-tabs">
<article :class="ui.infoTabPanel">
<div :class="ui.infoTabList" role="tablist" aria-label="銘柄別情報">
<button
id="stock-detail-tab"
:class="[ui.infoTabButton, activeStockInfoTab === 'detail' && ui.infoTabButtonActive]"
type="button"
role="tab"
:aria-selected="activeStockInfoTab === 'detail'"
aria-controls="stock-detail-panel"
@click="activeStockInfoTab = 'detail'"
>
詳細
<motion.span
v-if="activeStockInfoTab === 'detail'"
layout-id="stock-info-tab-indicator"
:class="ui.infoTabIndicator"
:transition="tabTransition"
/>
</button>
<button
id="stock-holding-tab"
:class="[
ui.infoTabButton,
activeStockInfoTab === 'holding' && ui.infoTabButtonActive,
]"
type="button"
role="tab"
:aria-selected="activeStockInfoTab === 'holding'"
aria-controls="stock-holding-panel"
@click="activeStockInfoTab = 'holding'"
>
保有
<motion.span
v-if="activeStockInfoTab === 'holding'"
layout-id="stock-info-tab-indicator"
:class="ui.infoTabIndicator"
:transition="tabTransition"
/>
</button>
</div>
<div :class="ui.infoTabBody">
<AnimatePresence mode="wait" :initial="false">
<motion.dl
v-if="activeStockInfoTab === 'detail'"
id="stock-detail-panel"
key="detail"
:class="ui.detailGrid"
role="tabpanel"
aria-labelledby="stock-detail-tab"
:initial="{ opacity: 0, y: 8 }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, y: -8 }"
:transition="tabContentTransition"
>
<div :class="ui.detailItem">
<dt class="text-xs text-[#9aa0a9]">国</dt>
<dd class="font-bold">{{ selectedStock.country }}</dd>
</div>
<div :class="ui.detailItem">
<dt class="text-xs text-[#9aa0a9]">市場</dt>
<dd class="font-bold">{{ selectedStock.market }}</dd>
</div>
<div :class="ui.detailItem">
<dt class="text-xs text-[#9aa0a9]">業種</dt>
<dd class="font-bold">{{ selectedStock.sector }}</dd>
</div>
<div :class="ui.detailItem">
<dt class="text-xs text-[#9aa0a9]">始値</dt>
<dd class="font-bold">
<template v-if="hasQuote(selectedStock)">{{
currency(selectedStock.open)
}}</template>
<Spinner v-else size="sm" />
</dd>
</div>
<div :class="ui.detailItem">
<dt class="text-xs text-[#9aa0a9]">高値</dt>
<dd class="font-bold">
<template v-if="hasQuote(selectedStock)">{{
currency(selectedStock.high)
}}</template>
<Spinner v-else size="sm" />
</dd>
</div>
<div :class="ui.detailItem">
<dt class="text-xs text-[#9aa0a9]">安値</dt>
<dd class="font-bold">
<template v-if="hasQuote(selectedStock)">{{
currency(selectedStock.low)
}}</template>
<Spinner v-else size="sm" />
</dd>
</div>
</motion.dl>
<motion.div
v-else
id="stock-holding-panel"
key="holding"
role="tabpanel"
aria-labelledby="stock-holding-tab"
:initial="{ opacity: 0, y: 8 }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, y: -8 }"
:transition="tabContentTransition"
>
<div v-if="selectedPosition" :class="ui.holdingNote">
保有 {{ selectedPosition.quantity }}株 / 平均
{{ currency(selectedPosition.avgPrice) }}
</div>
<p v-else :class="ui.holdingEmpty">保有なし</p>
</motion.div>
</AnimatePresence>
</div>
</article>
</LayoutGroup>
</div>
<div :class="ui.tradeSeparator" aria-hidden="true"></div>
<article :class="ui.ticketPanel">
<div :class="ui.ticketTop">
<UiSegmented v-model="tradeSide" :options="tradeSideOptions" />
</div>
<div :class="ui.ticketScroll">
<div :class="ui.ticketScrollInner">
<UiSegmented v-model="orderKind" :options="orderKindOptions" />
<div :class="ui.ticketBox">
<UiField
v-model="quantityInput"
label="発注株数"
type="number"
min="1"
placeholder="株数を入力"
/>
<details
:class="ui.advancedOptions"
@toggle="advancedOptionsOpen = ($event.target as HTMLDetailsElement).open"
>
<summary :class="ui.advancedSummary">
<span>Advanced Options</span>
<motion.span
:class="ui.advancedSummaryIconWrap"
:animate="{ rotate: advancedOptionsOpen ? 180 : 0 }"
:transition="advancedTransition"
>
<ChevronDown :class="ui.advancedSummaryIcon" aria-hidden="true" />
</motion.span>
</summary>
<motion.div
:class="ui.advancedBody"
:initial="{ opacity: 0, y: -4 }"
:animate="{ opacity: 1, y: 0 }"
:transition="advancedTransition"
>
<span :class="ui.advancedLabel">預り区分</span>
<UiSegmented
v-model="cashOrderAccountType"
:options="cashOrderAccountTypeOptions"
/>
<UiField
v-model="cashOrderMarket"
as="select"
label="注文市場"
:disabled="orderKind === 's'"
>
<option
v-for="option in orderMarketOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</UiField>
<template v-if="orderKind !== 's'">
<UiField v-model="cashOrderPriceCondition" as="select" label="執行条件">
<option
v-for="option in cashOrderPriceConditionOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</UiField>
<UiField
v-if="primaryPriceConditionRequiresPrice"
v-model="priceInput"
label="注文価格"
type="number"
min="1"
placeholder="価格を入力"
/>
<UiField v-model="cashOrderTerm" as="select" label="有効期限">
<option
v-for="option in cashOrderTermOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</UiField>
<UiField
v-if="cashOrderTerm === 'date'"
v-model="cashOrderDateInput"
label="指定日"
type="date"
/>
<UiField v-model="cashOrderMethod" as="select" label="特殊注文">
<option
v-for="option in cashOrderMethodOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</UiField>
<template v-if="cashOrderMethod !== 'normal'">
<UiField v-model="cashOrderTriggerZone" as="select" label="逆指値条件">
<option
v-for="option in cashOrderTriggerZoneOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</UiField>
<UiField
v-model="cashOrderTriggerPriceInput"
label="逆指値価格"
type="number"
min="1"
placeholder="価格を入力"
/>
</template>
<template v-if="cashOrderMethod === 'oco'">
<UiField
v-model="cashOrderSecondaryPriceCondition"
as="select"
label="OCO執行条件"
>
<option
v-for="option in cashOrderPriceConditionOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</UiField>
<UiField
v-if="secondaryPriceConditionRequiresPrice"
v-model="cashOrderSecondaryPriceInput"
label="OCO価格"
type="number"
min="1"
placeholder="価格を入力"
/>
</template>
</template>
</motion.div>
</details>
</div>
</div>
</div>
<div :class="ui.ticketBottom">
<div :class="ui.estimateSummary">
<span>概算金額</span>
<strong>{{ currency(estimatedAmount) }}</strong>
</div>
<div :class="ui.actions">
<UiButton type="button" :disabled="!canRequestCashEstimate" @click="emit('estimate')">
<FileCheck2 class="h-4 w-4" aria-hidden="true" />
見積
</UiButton>
<UiButton
variant="danger"
type="button"
:disabled="!canPlaceCashOrder"
@click="emit('confirmOrder')"
>
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
注文確認
</UiButton>
</div>
</div>
</article>
</section>
</template>
@@ -0,0 +1,301 @@
import type {
OrderPreview,
OrderRow,
Position,
RealtimePricePoint,
Stock,
} from '../../types/trading'
import { currency } from '../../utils/format'
export type RecordLike = Record<string, unknown>
export const emptyStock: Stock = {
code: '',
name: '未選択',
symbol: '',
country: '日本',
market: '',
sector: '',
price: 0,
change: 0,
changeAmount: 0,
volume: 0,
open: 0,
high: 0,
low: 0,
prevClose: 0,
sShare: false,
history: [0, 0],
box: { min: 0, q1: 0, median: 0, q3: 0, max: 0 },
}
export const isRecord = (value: unknown): value is RecordLike =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
export const asRecord = (value: unknown): RecordLike => (isRecord(value) ? value : {})
export const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
export const numberValue = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const normalized = value.replace(/[^\d.-]/g, '')
if (!normalized) return fallback
const parsed = Number(normalized)
if (Number.isFinite(parsed)) return parsed
}
const record = asRecord(value)
if ('value' in record) return numberValue(record.value, fallback)
if ('text' in record) return numberValue(record.text, fallback)
return fallback
}
const nullableNumberValue = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const normalized = value.replace(/[^\d.-]/g, '')
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
const record = asRecord(value)
if ('value' in record) return nullableNumberValue(record.value)
if ('text' in record) return nullableNumberValue(record.text)
return null
}
export const textValue = (value: unknown, fallback = ''): string =>
typeof value === 'string' && value ? value : fallback
export const issueFrom = (value: unknown) => {
const issue = asRecord(value)
return {
code: textValue(issue.code, textValue(issue.issueCode)),
market: textValue(issue.market, textValue(issue.marketCode, '')),
name: textValue(
issue.name,
textValue(issue.issueName, textValue(issue.stockName, textValue(issue.displayName))),
),
}
}
const quoteFrom = (value: unknown) => {
const quote = asRecord(value)
return {
price: numberValue(quote.price),
change: numberValue(quote.change),
changeRate: numberValue(quote.changeRate),
open: numberValue(quote.open),
high: numberValue(quote.high),
low: numberValue(quote.low),
prevClose: numberValue(quote.previousClose),
volume: numberValue(quote.volume),
}
}
const historyFromQuote = (quote: ReturnType<typeof quoteFrom>) => {
const base = quote.prevClose || quote.open || quote.price
const low = quote.low || Math.min(base, quote.price)
const high = quote.high || Math.max(base, quote.price)
return [base, quote.open || base, low, (low + high) / 2, high, quote.price]
}
const boxFromHistory = (history: number[]) => {
const sorted = [...history].sort((a, b) => a - b)
const at = (pct: number) => sorted[Math.floor((sorted.length - 1) * pct)] ?? 0
return { min: at(0), q1: at(0.25), median: at(0.5), q3: at(0.75), max: at(1) }
}
export const stockFromIssue = (issue: ReturnType<typeof issueFrom>): Stock => ({
code: issue.code,
name: issue.name || issue.code,
symbol: issue.code ? `${issue.code}${issue.market ? `.${issue.market}` : ''}` : '',
country: '日本',
market: issue.market,
sector: '',
price: 0,
change: 0,
changeAmount: 0,
volume: 0,
open: 0,
high: 0,
low: 0,
prevClose: 0,
sShare: true,
history: [0, 0],
box: { min: 0, q1: 0, median: 0, q3: 0, max: 0 },
})
export const stockFromPosition = (position: Position): Stock => {
const price = position.quantity ? Math.round(position.marketValue / position.quantity) : 0
return {
...stockFromIssue({
code: position.code,
market: position.market,
name: position.name,
}),
price,
history: [price, price],
box: { min: price, q1: price, median: price, q3: price, max: price },
}
}
export const stockFromBoard = (
value: unknown,
fallbackIssue?: ReturnType<typeof issueFrom>,
): Stock => {
const board = asRecord(value)
const quoteRecord = asRecord(board.quote)
const boardIssue = issueFrom(board.issue)
const quoteIssue = issueFrom(quoteRecord.issue)
const issue = {
code: quoteIssue.code || boardIssue.code || fallbackIssue?.code || '',
market: quoteIssue.market || boardIssue.market || fallbackIssue?.market || '',
name: quoteIssue.name || boardIssue.name || fallbackIssue?.name || '',
}
const quote = quoteFrom(quoteRecord)
const codeAsNumber = Number(issue.code)
const quotePrice = quote.price === codeAsNumber ? 0 : quote.price
const price = quotePrice || quote.prevClose || quote.open
const history = historyFromQuote({ ...quote, price })
const hasValidQuote = price > 0
return {
code: issue.code,
name: issue.name,
symbol: issue.code ? `${issue.code}${issue.market ? `.${issue.market}` : ''}` : '',
country: '日本',
market: issue.market,
sector: '',
price,
change: hasValidQuote ? quote.changeRate : 0,
changeAmount: hasValidQuote ? quote.change : 0,
volume: hasValidQuote ? quote.volume : 0,
open: hasValidQuote ? quote.open : 0,
high: hasValidQuote ? quote.high : 0,
low: hasValidQuote ? quote.low : 0,
prevClose: hasValidQuote ? quote.prevClose : 0,
sShare: true,
history,
box: boxFromHistory(history),
}
}
const chartDateTimeToIso = (value: string) => {
const digits = value.replace(/\D/g, '')
if (digits.length < 8) return ''
const year = Number(digits.slice(0, 4))
const month = Number(digits.slice(4, 6))
const day = Number(digits.slice(6, 8))
const hour = Number(digits.slice(8, 10) || '0')
const minute = Number(digits.slice(10, 12) || '0')
const date = new Date(year, month - 1, day, hour, minute)
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
}
export const pricePointsFromIssueChart = (value: unknown): RealtimePricePoint[] =>
asArray(asRecord(value).prices)
.map((entry) => {
const price = asRecord(entry)
const at = chartDateTimeToIso(textValue(price.dateTime))
return { at, price: numberValue(price.close) }
})
.filter((point) => point.at && point.price > 0)
export const positionFromApi = (value: unknown): Position | null => {
const item = asRecord(value)
const issue = issueFrom(item.issue)
if (!issue.code) return null
const quantity = numberValue(item.quantity)
const avgPrice = numberValue(item.averagePrice ?? item.purchasePrice)
const profitLoss = numberValue(item.profitLoss)
const costBasis = avgPrice * quantity
const profitLossRate =
numberValue(item.profitLossRate) || (costBasis ? (profitLoss / costBasis) * 100 : 0)
return {
code: issue.code,
name: issue.name,
market: issue.market,
quantity,
avgPrice,
marketValue: numberValue(item.marketValue ?? item.valuationPrice),
profitLoss,
profitLossRate,
type: textValue(item.depositTypeText) || undefined,
}
}
const orderStatusText = (status: string): OrderRow['status'] => {
if (status === 'open') return '注文中'
if (status === 'cancelled' || status === 'expired' || status === 'rejected') return '取消済'
return '約定済'
}
export const orderFromApi = (value: unknown): OrderRow | null => {
const item = asRecord(value)
const issue = issueFrom(item.issue)
if (!issue.code) return null
const side = item.side === 'sell' ? 'sell' : 'buy'
const kind = item.kind === 's' ? 's' : 'standard'
const status = orderStatusText(textValue(item.status, 'executed'))
const executedQuantity = nullableNumberValue(item.executedQuantity)
const orderedQuantity = nullableNumberValue(item.quantity)
const unexecutedQuantity = nullableNumberValue(item.unexecutedQuantity)
const quantity =
status === '約定済'
? (executedQuantity ?? orderedQuantity ?? unexecutedQuantity)
: (orderedQuantity ?? unexecutedQuantity ?? executedQuantity)
const price =
status === '約定済'
? (nullableNumberValue(item.executedPrice) ?? nullableNumberValue(item.price))
: (nullableNumberValue(item.price) ?? nullableNumberValue(item.executedPrice))
return {
id: textValue(item.id, textValue(item.orderNumber, `${issue.code}-${item.orderedAt ?? ''}`)),
date: textValue(item.orderedAt, textValue(item.expiresAt)),
stock: issue.name || issue.code,
side,
kind,
quantity,
price,
status,
orderNumber: textValue(item.orderNumber),
tradeId: textValue(item.tradeId),
}
}
export const orderHistoryKey = (order: OrderRow) =>
[order.orderNumber || order.id, order.tradeId, order.stock, order.date].filter(Boolean).join(':')
export const orderQuantityText = (order: OrderRow) =>
typeof order.quantity === 'number' ? `${order.quantity}株` : '数量不明'
export const orderAmountText = (order: OrderRow) =>
typeof order.price === 'number' && typeof order.quantity === 'number'
? currency(order.price * order.quantity)
: '約定代金不明'
export const orderHistoryResultNotice = (value: unknown) => {
const error = asRecord(asRecord(value).error)
const code = textValue(error.code, textValue(error.status))
if (!code) return ''
const message = textValue(error.message)
return message ? `${code}: ${message}` : code
}
export const isOrderPreview = (value: unknown): value is OrderPreview => {
if (!value || typeof value !== 'object') return false
const preview = value as Partial<OrderPreview>
return (
typeof preview.issue === 'object' &&
preview.issue !== null &&
typeof preview.issue.code === 'string' &&
typeof preview.issue.market === 'string' &&
typeof preview.side === 'string' &&
typeof preview.quantity === 'number' &&
Array.isArray(preview.warnings)
)
}
export const fulfilledValues = <T>(results: Array<PromiseSettledResult<T>>): T[] =>
results
.filter((result): result is PromiseFulfilledResult<T> => result.status === 'fulfilled')
.map((result) => result.value)
@@ -0,0 +1,899 @@
import { computed, ref, watch, type Ref } from 'vue'
import { createRpcSocket } from '../../api'
import type {
ChartMode,
CashOrderAccountType,
CashOrderMarket,
CashOrderMethod,
CashOrderPriceCondition,
CashOrderTerm,
CashOrderTriggerZone,
JsonRpcResponse,
OrderKind,
OrderPreview,
OrderRow,
Position,
RealtimePricePoint,
RpcMessage,
Stock,
TradeSide,
} from '../../types/trading'
import {
asArray,
asRecord,
emptyStock,
fulfilledValues,
isOrderPreview,
issueFrom,
numberValue,
orderFromApi,
orderHistoryKey,
orderHistoryResultNotice,
pricePointsFromIssueChart,
positionFromApi,
stockFromBoard,
stockFromIssue,
stockFromPosition,
textValue,
type RecordLike,
} from './trading-data'
type RpcResolver = {
resolve: (value: unknown) => void
reject: (reason: Error) => void
}
const priceBasedCashOrderConditions = new Set<CashOrderPriceCondition>([
'limit',
'limitAtOpen',
'limitAtClose',
'limitIoc',
'funari',
])
const cashOrderPriceConditionRequiresPrice = (condition: CashOrderPriceCondition) =>
priceBasedCashOrderConditions.has(condition)
export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const selectedStockCode = ref('')
const viewedStockCodes = ref<string[]>([])
const tradeSide = ref<TradeSide>('buy')
const orderKind = ref<OrderKind>('s')
const cashOrderAccountType = ref<CashOrderAccountType>('specific')
const cashOrderMarket = ref<CashOrderMarket>('STK')
const cashOrderPriceCondition = ref<CashOrderPriceCondition>('market')
const cashOrderTerm = ref<CashOrderTerm>('day')
const cashOrderDateInput = ref('')
const cashOrderMethod = ref<CashOrderMethod>('normal')
const cashOrderTriggerZone = ref<CashOrderTriggerZone>('above')
const cashOrderTriggerPriceInput = ref('')
const cashOrderSecondaryPriceCondition = ref<CashOrderPriceCondition>('limit')
const cashOrderSecondaryPriceInput = ref('')
const quantityInput = ref('')
const priceInput = ref('')
const chartMode = ref<ChartMode>('line')
const showSearch = ref(false)
const searchQuery = ref('')
const countryFilter = ref('all')
const marketFilter = ref('all')
const showEstimateDialog = ref(false)
const showOrderDialog = ref(false)
const pendingCashEstimateId = ref<number | null>(null)
const lastCashEstimate = ref<OrderPreview | null>(null)
const lastCashEstimateKey = ref('')
const ws = ref<WebSocket | null>(null)
const rpcPending = new Map<number, RpcResolver>()
const sbiConnected = ref(false)
const dataLoading = ref(false)
const searchLoading = ref(false)
const buyingPower = ref(0)
const holdingsMarketValue = ref(0)
const totalProfitLoss = ref(0)
const totalProfitLossRate = ref(0)
const orders = ref<OrderRow[]>([])
const orderHistoryLoaded = ref(false)
const orderHistoryNotice = ref('')
const positions = ref<Position[]>([])
const stocks = ref<Stock[]>([])
const historicalPricePoints = ref<RealtimePricePoint[]>([])
const realtimePricePoints = ref<RealtimePricePoint[]>([])
const pricePolling = ref(false)
let rpcId = 0
let boardPollingSubscriptionId = ''
let boardPollingRequestId = 0
let chartHistoryRequestId = 0
const maxRealtimePricePoints = 120
const maxChartPricePoints = 240
const errorMessage = (cause: unknown, fallback: string) =>
cause instanceof Error ? cause.message : fallback
const reportDataError = (message: string, cause?: unknown) => {
if (cause) {
console.error(`[csbie-ui] データ取得エラー: ${message}`, cause)
return
}
console.error(`[csbie-ui] データ取得エラー: ${message}`)
}
const selectedStock = computed(
() =>
stocks.value.find((stock) => stock.code === selectedStockCode.value) ??
stocks.value[0] ??
emptyStock,
)
const socketReady = computed(() => ws.value?.readyState === WebSocket.OPEN)
const connected = computed(() => sbiConnected.value && socketReady.value)
const orderQuantity = computed(() => Number(quantityInput.value || 0))
const orderPrice = computed(() => Number(priceInput.value || selectedStock.value.price))
const cashOrderPrimaryRequiresPrice = computed(() =>
cashOrderPriceConditionRequiresPrice(cashOrderPriceCondition.value),
)
const cashOrderSecondaryRequiresPrice = computed(() =>
cashOrderPriceConditionRequiresPrice(cashOrderSecondaryPriceCondition.value),
)
const cashOrderTriggerPrice = computed(() => Number(cashOrderTriggerPriceInput.value || 0))
const cashOrderSecondaryPrice = computed(() => Number(cashOrderSecondaryPriceInput.value || 0))
const resolvedCashOrderMarket = computed(() => {
if (orderKind.value === 's') return 'STK'
return cashOrderMarket.value === 'auto' ? selectedStock.value.market : cashOrderMarket.value
})
const estimatedAmount = computed(() => Math.max(0, orderQuantity.value * orderPrice.value))
const hasQuote = (stock: Stock) => stock.price > 0
const hasAccountSummary = computed(
() =>
connected.value ||
Boolean(positions.value.length) ||
orderHistoryLoaded.value ||
holdingsMarketValue.value > 0 ||
buyingPower.value > 0,
)
const showPortfolioSpinner = computed(() => dataLoading.value || !hasAccountSummary.value)
const cashOrderKey = computed(() =>
JSON.stringify({
issueCode: selectedStock.value.code,
market: resolvedCashOrderMarket.value,
side: tradeSide.value,
quantity: orderQuantity.value,
kind: orderKind.value,
accountType: cashOrderAccountType.value,
priceCondition: orderKind.value === 's' ? undefined : cashOrderPriceCondition.value,
price:
orderKind.value !== 's' && cashOrderPrimaryRequiresPrice.value
? orderPrice.value
: undefined,
orderTerm: orderKind.value === 's' ? undefined : cashOrderTerm.value,
orderDate:
orderKind.value !== 's' && cashOrderTerm.value === 'date'
? cashOrderDateInput.value
: undefined,
orderMethod: orderKind.value === 's' ? undefined : cashOrderMethod.value,
triggerZone:
orderKind.value !== 's' && cashOrderMethod.value !== 'normal'
? cashOrderTriggerZone.value
: undefined,
triggerPrice:
orderKind.value !== 's' && cashOrderMethod.value !== 'normal'
? cashOrderTriggerPrice.value
: undefined,
secondaryPriceCondition:
orderKind.value !== 's' && cashOrderMethod.value === 'oco'
? cashOrderSecondaryPriceCondition.value
: undefined,
secondaryPrice:
orderKind.value !== 's' &&
cashOrderMethod.value === 'oco' &&
cashOrderSecondaryRequiresPrice.value
? cashOrderSecondaryPrice.value
: undefined,
}),
)
const canRequestCashEstimate = computed(() => {
if (!connected.value || !selectedStock.value.code || orderQuantity.value <= 0) return false
if (orderKind.value === 's') return true
if (cashOrderPrimaryRequiresPrice.value && orderPrice.value <= 0) return false
if (cashOrderTerm.value === 'date' && !cashOrderDateInput.value) return false
if (cashOrderMethod.value !== 'normal' && cashOrderTriggerPrice.value <= 0) return false
if (
cashOrderMethod.value === 'oco' &&
cashOrderSecondaryRequiresPrice.value &&
cashOrderSecondaryPrice.value <= 0
) {
return false
}
return true
})
const canPlaceCashOrder = computed(
() =>
canRequestCashEstimate.value &&
Boolean(lastCashEstimate.value) &&
lastCashEstimateKey.value === cashOrderKey.value,
)
const countries = computed(() => [...new Set(stocks.value.map((stock) => stock.country))])
const markets = computed(() => [
...new Set(stocks.value.map((stock) => stock.market).filter(Boolean)),
])
const stockByCode = computed(() => new Map(stocks.value.map((stock) => [stock.code, stock])))
const viewedStocks = computed(() =>
viewedStockCodes.value
.map((code) => stockByCode.value.get(code))
.filter((stock): stock is Stock => Boolean(stock)),
)
const filteredStocks = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
const matchesFilters = (stock: Stock) => {
const matchesCountry = countryFilter.value === 'all' || stock.country === countryFilter.value
const matchesMarket = marketFilter.value === 'all' || stock.market === marketFilter.value
return matchesCountry && matchesMarket
}
if (!query) return viewedStocks.value.filter(matchesFilters)
return stocks.value.filter((stock) => {
const matchesQuery =
stock.name.toLowerCase().includes(query) ||
stock.code.includes(query) ||
stock.symbol.toLowerCase().includes(query)
return matchesQuery && matchesFilters(stock)
})
})
const selectedPosition = computed(() =>
positions.value.find((position) => position.code === selectedStock.value.code),
)
const recentOrders = computed(() => orders.value.slice(0, 2))
const totalAssetValue = computed(() => holdingsMarketValue.value + buyingPower.value)
const stockAssetRatio = computed(() => {
if (!totalAssetValue.value) return 0
return (holdingsMarketValue.value / totalAssetValue.value) * 100
})
const cashAssetRatio = computed(() => {
if (!totalAssetValue.value) return 0
return (buyingPower.value / totalAssetValue.value) * 100
})
const chartPricePoints = computed(() =>
[...historicalPricePoints.value, ...realtimePricePoints.value].slice(-maxChartPricePoints),
)
const boxPlotStyle = computed(() => {
const box = selectedStock.value.box
const range = Math.max(1, box.max - box.min)
const toPct = (value: number) => ((value - box.min) / range) * 100
return {
min: `${toPct(box.min)}%`,
q1: `${toPct(box.q1)}%`,
median: `${toPct(box.median)}%`,
q3: `${toPct(box.q3)}%`,
max: `${toPct(box.max)}%`,
}
})
const recordViewedStock = (code: string) => {
if (!code) return
viewedStockCodes.value = [code, ...viewedStockCodes.value.filter((entry) => entry !== code)]
}
const selectStock = (stock: Stock) => {
selectedStockCode.value = stock.code
recordViewedStock(stock.code)
showSearch.value = false
lastCashEstimate.value = null
lastCashEstimateKey.value = ''
}
const rejectPendingRpc = (reason: Error) => {
for (const pending of rpcPending.values()) pending.reject(reason)
rpcPending.clear()
}
const appendRealtimePricePoint = (price: number, at = new Date()) => {
if (!Number.isFinite(price) || price <= 0) return
realtimePricePoints.value = [
...realtimePricePoints.value,
{ at: at.toISOString(), price },
].slice(-maxRealtimePricePoints)
}
const stopBoardPolling = () => {
const subscriptionId = boardPollingSubscriptionId
boardPollingSubscriptionId = ''
boardPollingRequestId += 1
pricePolling.value = false
if (subscriptionId) {
call('market.issue.pollBoard.unsubscribe', { subscriptionId })
}
}
const handleBoardPollingUpdate = (params: unknown) => {
const payload = asRecord(params)
const subscriptionId = textValue(payload.subscriptionId)
if (!subscriptionId || subscriptionId !== boardPollingSubscriptionId) return
const stock = stockFromBoard(payload.board, {
code: selectedStock.value.code,
market: selectedStock.value.market,
name: selectedStock.value.name,
})
if (stock.code && stock.code !== selectedStock.value.code) return
mergeStocks([stock])
appendRealtimePricePoint(stock.price)
}
const handleRpcMessage = (data: string) => {
let response: JsonRpcResponse
try {
response = JSON.parse(data) as JsonRpcResponse
} catch {
return
}
if (response.method === 'market.issue.pollBoard.update') {
handleBoardPollingUpdate(response.params)
return
}
if (response.method === 'market.issue.pollBoard.error') {
const payload = asRecord(response.params)
if (textValue(payload.subscriptionId) === boardPollingSubscriptionId) {
pricePolling.value = false
reportDataError(textValue(payload.message, '価格ポーリングに失敗しました'))
}
return
}
if (typeof response.id !== 'number') return
const pending = rpcPending.get(response.id)
if (!pending) return
rpcPending.delete(response.id)
if (response.error) {
pending.reject(new Error(response.error.message || 'RPC request failed'))
} else {
pending.resolve(response.result)
}
}
const call = (method: string, params?: unknown) => {
const socket = ws.value
if (!socket || socket.readyState !== WebSocket.OPEN) return undefined
const payload: RpcMessage = { id: ++rpcId, method, params }
socket.send(JSON.stringify({ jsonrpc: '2.0', ...payload }))
return payload.id
}
const rpcCall = async <T>(method: string, params?: unknown): Promise<T> => {
const id = call(method, params)
if (!id) throw new Error('SBI session is not connected')
return new Promise<T>((resolve, reject) => {
rpcPending.set(id, {
resolve: (value) => resolve(value as T),
reject,
})
})
}
const rpcCallOptional = async <T>(
method: string,
params?: unknown,
timeoutMs = 8_000,
): Promise<T> => {
const id = call(method, params)
if (!id) throw new Error('SBI session is not connected')
return new Promise<T>((resolve, reject) => {
const timeout = window.setTimeout(() => {
rpcPending.delete(id)
reject(new Error(`${method} timed out`))
}, timeoutMs)
rpcPending.set(id, {
resolve: (value) => {
window.clearTimeout(timeout)
resolve(value as T)
},
reject: (reason) => {
window.clearTimeout(timeout)
reject(reason)
},
})
})
}
const mergeStocks = (nextStocks: Stock[]) => {
const merged = new Map(stocks.value.map((stock) => [stock.code, stock]))
for (const stock of nextStocks) {
if (!stock.code) continue
const current = merged.get(stock.code)
merged.set(stock.code, {
...current,
...stock,
name: stock.name || current?.name || stock.code,
price: stock.price || current?.price || 0,
change: stock.change || current?.change || 0,
changeAmount: stock.changeAmount || current?.changeAmount || 0,
history: stock.price ? stock.history : (current?.history ?? stock.history),
box: stock.price ? stock.box : (current?.box ?? stock.box),
})
}
stocks.value = [...merged.values()]
if (!selectedStockCode.value) {
const nextCode = stocks.value[0]?.code ?? ''
if (nextCode) {
selectedStockCode.value = nextCode
recordViewedStock(nextCode)
}
}
}
const loadOrderHistoryFromSdk = async () => {
orderHistoryLoaded.value = false
orderHistoryNotice.value = ''
const [openOrdersResult, executionsTodayResult] = await Promise.allSettled([
rpcCallOptional<RecordLike>('orders.inquiry.open'),
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday'),
])
if (openOrdersResult.status === 'rejected' && executionsTodayResult.status === 'rejected') {
throw openOrdersResult.reason
}
const nextOrders = fulfilledValues([openOrdersResult, executionsTodayResult])
.flatMap((orderList) => asArray(orderList.orders))
.map(orderFromApi)
.filter((order): order is OrderRow => Boolean(order))
const deduped = new Map<string, OrderRow>()
for (const order of nextOrders) deduped.set(orderHistoryKey(order), order)
orders.value = [...deduped.values()]
orderHistoryLoaded.value = true
if (!orders.value.length) {
const notices = fulfilledValues([openOrdersResult, executionsTodayResult])
.map(orderHistoryResultNotice)
.filter(Boolean)
orderHistoryNotice.value = [...new Set(notices)].join(' / ')
}
}
const loadTradingData = async () => {
dataLoading.value = true
try {
const cashPositions = await rpcCallOptional<RecordLike>(
'account.positions.cash',
undefined,
15_000,
)
const nextPositions = asArray(cashPositions.positions)
.map(positionFromApi)
.filter((position): position is Position => Boolean(position))
positions.value = nextPositions
mergeStocks(nextPositions.map(stockFromPosition))
const nextHoldingsMarketValue = numberValue(
cashPositions.totalMarketValue,
nextPositions.reduce((sum, position) => sum + position.marketValue, 0),
)
holdingsMarketValue.value = nextHoldingsMarketValue
totalProfitLoss.value = numberValue(cashPositions.totalProfitLoss)
totalProfitLossRate.value = numberValue(cashPositions.totalProfitLossRate)
const [orderHistoryResult, powerResult] = await Promise.allSettled([
loadOrderHistoryFromSdk(),
rpcCallOptional<RecordLike>('account.power.buyingPower'),
])
if (powerResult.status === 'fulfilled') {
buyingPower.value = numberValue(
powerResult.value.cashBuyingPower ?? powerResult.value.withdrawableAmount,
)
}
if (orderHistoryResult.status === 'rejected') {
reportDataError(
errorMessage(orderHistoryResult.reason, '取引履歴の取得に失敗しました'),
orderHistoryResult.reason,
)
}
const boards = await Promise.allSettled(
nextPositions.slice(0, 20).map((position) =>
rpcCallOptional<RecordLike>(
'market.issue.board',
{
issueCode: position.code,
},
8_000,
),
),
)
mergeStocks(
boards.flatMap((result, index) =>
result.status === 'fulfilled'
? [
stockFromBoard(result.value, {
code: nextPositions[index]?.code ?? '',
market: nextPositions[index]?.market ?? '',
name: nextPositions[index]?.name ?? '',
}),
]
: [],
),
)
} finally {
dataLoading.value = false
}
}
const connect = () => {
const previousSocket = ws.value
rejectPendingRpc(new Error('RPC socket reconnecting'))
stopBoardPolling()
previousSocket?.close()
sbiConnected.value = false
dataLoading.value = true
if (!selectedPasskeyId.value) {
dataLoading.value = false
reportDataError('SBIパスキーを選択してください')
return
}
const socket = createRpcSocket()
socket.addEventListener('open', async () => {
try {
await rpcCall('sbi.connect', { passkeyId: selectedPasskeyId.value })
sbiConnected.value = true
await loadTradingData()
} catch (cause) {
sbiConnected.value = false
reportDataError(errorMessage(cause, '接続に失敗しました'), cause)
socket.close()
} finally {
dataLoading.value = false
}
})
socket.addEventListener('message', (event) => handleRpcMessage(String(event.data)))
socket.addEventListener('error', () => {
if (ws.value !== socket) return
reportDataError('SBI接続に失敗しました')
})
socket.addEventListener('close', () => {
if (ws.value !== socket) return
rejectPendingRpc(new Error('RPC socket closed'))
boardPollingSubscriptionId = ''
chartHistoryRequestId += 1
historicalPricePoints.value = []
realtimePricePoints.value = []
pricePolling.value = false
sbiConnected.value = false
dataLoading.value = false
})
ws.value = socket
}
const startBoardPolling = async () => {
stopBoardPolling()
realtimePricePoints.value = []
const stock = selectedStock.value
if (!connected.value || !stock.code) return
appendRealtimePricePoint(stock.price)
const requestId = ++boardPollingRequestId
try {
const subscribed = await rpcCall<RecordLike>('market.issue.pollBoard.subscribe', {
issueCode: stock.code,
market: stock.market || undefined,
})
if (requestId !== boardPollingRequestId) {
const staleSubscriptionId = textValue(subscribed.subscriptionId)
if (staleSubscriptionId) {
call('market.issue.pollBoard.unsubscribe', { subscriptionId: staleSubscriptionId })
}
return
}
boardPollingSubscriptionId = textValue(subscribed.subscriptionId)
pricePolling.value = Boolean(boardPollingSubscriptionId)
} catch (cause) {
if (requestId === boardPollingRequestId) {
pricePolling.value = false
reportDataError(errorMessage(cause, '価格ポーリングの開始に失敗しました'), cause)
}
}
}
const loadSelectedStockChart = async () => {
historicalPricePoints.value = []
const stock = selectedStock.value
if (!connected.value || !stock.code) return
const requestId = ++chartHistoryRequestId
try {
const chart = await rpcCall<RecordLike>('market.issue.chart', {
issueCode: stock.code,
market: stock.market || undefined,
period: 'day',
count: 120,
})
if (requestId !== chartHistoryRequestId) return
historicalPricePoints.value = pricePointsFromIssueChart(chart)
} catch (cause) {
if (requestId === chartHistoryRequestId) {
reportDataError(errorMessage(cause, '価格履歴の取得に失敗しました'), cause)
}
}
}
const searchIssues = async (query: string) => {
if (!connected.value || query.trim().length < 2) return
const result = await rpcCall<RecordLike>('market.issue.search', { query, limit: 12 })
const issues = asArray(result.issues)
.map(issueFrom)
.filter((issue) => issue.code)
mergeStocks(issues.map(stockFromIssue))
const boards = await Promise.allSettled(
issues.map((issue) =>
rpcCall<RecordLike>('market.issue.board', {
issueCode: issue.code,
market: issue.market || undefined,
}).then((board) => stockFromBoard(board, issue)),
),
)
mergeStocks(
boards
.filter((result): result is PromiseFulfilledResult<Stock> => result.status === 'fulfilled')
.map((result) => result.value),
)
}
const estimateCashOrder = async () => {
if (!canRequestCashEstimate.value) return
lastCashEstimate.value = null
lastCashEstimateKey.value = ''
pendingCashEstimateId.value = null
const preview = await rpcCall<unknown>('orders.cash.estimate', cashOrderParams())
if (isOrderPreview(preview)) {
lastCashEstimate.value = preview
lastCashEstimateKey.value = cashOrderKey.value
showEstimateDialog.value = true
}
}
const cashOrderParams = () => ({
issueCode: selectedStock.value.code,
market: resolvedCashOrderMarket.value || undefined,
side: tradeSide.value,
quantity: orderQuantity.value,
kind: orderKind.value === 's' ? 's' : undefined,
accountType: cashOrderAccountType.value,
price:
orderKind.value !== 's' && cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined,
priceCondition: orderKind.value !== 's' ? cashOrderPriceCondition.value : undefined,
orderTerm: orderKind.value !== 's' ? cashOrderTerm.value : undefined,
orderDate:
orderKind.value !== 's' && cashOrderTerm.value === 'date'
? cashOrderDateInput.value
: undefined,
orderMethod: orderKind.value !== 's' ? cashOrderMethod.value : undefined,
triggerZone:
orderKind.value !== 's' && cashOrderMethod.value !== 'normal'
? cashOrderTriggerZone.value
: undefined,
triggerPrice:
orderKind.value !== 's' && cashOrderMethod.value !== 'normal'
? cashOrderTriggerPrice.value
: undefined,
secondaryPriceCondition:
orderKind.value !== 's' && cashOrderMethod.value === 'oco'
? cashOrderSecondaryPriceCondition.value
: undefined,
secondaryPrice:
orderKind.value !== 's' &&
cashOrderMethod.value === 'oco' &&
cashOrderSecondaryRequiresPrice.value
? cashOrderSecondaryPrice.value
: undefined,
})
const askPlaceOrder = () => {
if (!canPlaceCashOrder.value) return
showEstimateDialog.value = false
showOrderDialog.value = true
}
const placeCashOrder = async () => {
if (!canPlaceCashOrder.value || !lastCashEstimate.value) return
const receipt = await rpcCall<RecordLike>('orders.cash.place', {
...cashOrderParams(),
confirmationId: lastCashEstimate.value.confirmationId,
allowTrading: true,
})
orders.value = [
{
id: textValue(receipt.orderId, `ord-${Date.now()}`),
date: textValue(receipt.acceptedAt, new Date().toLocaleString('ja-JP')),
stock: selectedStock.value.name,
side: tradeSide.value,
kind: orderKind.value,
quantity: orderQuantity.value,
price: orderPrice.value,
status: '注文中',
},
...orders.value,
]
showOrderDialog.value = false
await loadTradingData()
}
const cancelOrder = async (order: OrderRow) => {
await rpcCall('orders.cash.placeCancel', {
orderNumber: order.orderNumber || order.id,
orderId: order.id,
tradeId: order.tradeId || undefined,
allowTrading: true,
})
order.status = '取消済'
await loadTradingData()
}
const downloadCsv = () => {
const header = ['code', 'name', 'symbol', 'market', 'price'].join(',')
const rows = selectedStock.value.history.map((price) =>
[
selectedStock.value.code,
selectedStock.value.name,
selectedStock.value.symbol,
selectedStock.value.market,
price,
]
.map((value) => `"${String(value).replaceAll('"', '""')}"`)
.join(','),
)
const blob = new Blob([[header, ...rows].join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${selectedStock.value.code}-history.csv`
anchor.click()
URL.revokeObjectURL(url)
}
const openTradeForStock = (stock: Stock, navigate: () => void) => {
selectStock(stock)
navigate()
}
const openTradeForPosition = (code: string, navigate: () => void) => {
const stock = stocks.value.find((candidate) => candidate.code === code)
openTradeForStock(stock ?? selectedStock.value, navigate)
}
let searchTimer: ReturnType<typeof setTimeout> | undefined
let searchRequestId = 0
watch(orderKind, (kind) => {
if (kind === 's') {
cashOrderMarket.value = 'STK'
cashOrderPriceCondition.value = 'market'
cashOrderTerm.value = 'day'
cashOrderDateInput.value = ''
cashOrderMethod.value = 'normal'
cashOrderTriggerPriceInput.value = ''
cashOrderSecondaryPriceInput.value = ''
priceInput.value = ''
return
}
if (cashOrderMarket.value === 'STK') {
cashOrderMarket.value = 'auto'
}
})
watch(cashOrderPriceCondition, (condition) => {
if (!cashOrderPriceConditionRequiresPrice(condition)) priceInput.value = ''
})
watch(cashOrderSecondaryPriceCondition, (condition) => {
if (!cashOrderPriceConditionRequiresPrice(condition)) cashOrderSecondaryPriceInput.value = ''
})
watch(cashOrderTerm, (term) => {
if (term !== 'date') cashOrderDateInput.value = ''
})
watch(cashOrderMethod, (method) => {
if (method === 'normal') {
cashOrderTriggerPriceInput.value = ''
cashOrderSecondaryPriceInput.value = ''
return
}
if (method === 'stop') cashOrderSecondaryPriceInput.value = ''
})
watch(searchQuery, (query) => {
clearTimeout(searchTimer)
const trimmed = query.trim()
if (trimmed.length < 2 || !connected.value) {
searchLoading.value = false
return
}
searchLoading.value = true
searchTimer = setTimeout(async () => {
const requestId = ++searchRequestId
try {
await searchIssues(query)
} catch (cause) {
reportDataError(errorMessage(cause, '銘柄検索に失敗しました'), cause)
} finally {
if (requestId === searchRequestId) {
searchLoading.value = false
}
}
}, 350)
})
watch(
[connected, () => selectedStock.value.code, () => selectedStock.value.market],
() => {
void loadSelectedStockChart()
void startBoardPolling()
},
{ immediate: true },
)
return {
selectedStockCode,
tradeSide,
orderKind,
cashOrderAccountType,
cashOrderMarket,
cashOrderPriceCondition,
cashOrderTerm,
cashOrderDateInput,
cashOrderMethod,
cashOrderTriggerZone,
cashOrderTriggerPriceInput,
cashOrderSecondaryPriceCondition,
cashOrderSecondaryPriceInput,
quantityInput,
priceInput,
chartMode,
showSearch,
searchQuery,
countryFilter,
marketFilter,
showEstimateDialog,
showOrderDialog,
lastCashEstimate,
connected,
dataLoading,
searchLoading,
buyingPower,
holdingsMarketValue,
totalProfitLoss,
totalProfitLossRate,
orders,
orderHistoryLoaded,
orderHistoryNotice,
positions,
realtimePricePoints,
chartPricePoints,
pricePolling,
selectedStock,
orderQuantity,
orderPrice,
cashOrderPrimaryRequiresPrice,
cashOrderTriggerPrice,
cashOrderSecondaryPrice,
estimatedAmount,
showPortfolioSpinner,
canRequestCashEstimate,
canPlaceCashOrder,
countries,
markets,
viewedStocks,
filteredStocks,
selectedPosition,
recentOrders,
totalAssetValue,
stockAssetRatio,
cashAssetRatio,
boxPlotStyle,
hasQuote,
selectStock,
connect,
loadTradingData,
estimateCashOrder,
askPlaceOrder,
placeCashOrder,
cancelOrder,
downloadCsv,
openTradeForStock,
openTradeForPosition,
}
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { router } from './router'
createApp(App).use(router).mount('#app')
+20
View File
@@ -0,0 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router'
const RouteStub = { template: '<span />' }
export const routeNames = ['portfolio', 'trade', 'history', 'settings'] as const
export type RouteName = (typeof routeNames)[number]
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/portfolio' },
{ path: '/portfolio', name: 'portfolio', component: RouteStub },
{ path: '/trade', name: 'trade', component: RouteStub },
{ path: '/history', name: 'history', component: RouteStub },
{ path: '/api-keys', redirect: '/settings' },
{ path: '/settings', name: 'settings', component: RouteStub },
{ path: '/oauth/authorize', name: 'oauthAuthorize', component: RouteStub },
{ path: '/:pathMatch(.*)*', redirect: '/portfolio' },
],
})
+14
View File
@@ -0,0 +1,14 @@
@import 'tailwindcss';
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
min-width: 320px;
overflow: hidden;
background: #101418;
}
+162
View File
@@ -0,0 +1,162 @@
export const ui = {
appShell: 'grid h-dvh overflow-hidden grid-cols-[7rem_minmax(0,1fr)] bg-[#101418] text-[#e3e3e9]',
sidebar: 'flex h-full min-h-0 flex-col items-center border-r border-[#2f3338] bg-[#191c20] py-4',
brandMark:
'grid h-14 w-14 place-items-center rounded-[18px] bg-[#a8c7fa] text-3xl font-black text-[#0b305f] shadow-lg shadow-black/20',
navStack: 'mt-10 grid w-full gap-4 px-2',
navButton:
'grid min-h-16 place-items-center gap-1 rounded-[20px] bg-transparent p-1 text-xs font-semibold text-[#c3c7cf] transition hover:bg-[#22272e]',
navButtonActive: 'text-[#d3e3fd]',
navIcon: 'grid h-8 w-16 place-items-center rounded-full text-[#c3c7cf]',
navIconActive: 'bg-[#263141] text-[#d3e3fd]',
workspace: 'flex min-h-0 min-w-0 flex-col gap-7 overflow-y-auto px-8 pt-8 pb-8',
topbar: 'flex shrink-0 items-center justify-between gap-4',
tradeTopbar: 'hidden',
authPanel: 'grid justify-center pt-12',
panel:
'grid content-start gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-lg shadow-black/15',
loginPanel: 'w-[28rem]',
panelHead: 'flex items-center justify-between gap-3',
eyebrow: 'mb-1 text-xs font-black uppercase text-[#9aa0a9]',
dashboardGrid:
'grid min-h-0 flex-1 grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)] grid-rows-[auto_minmax(0,1fr)] items-stretch gap-6 overflow-hidden',
metricPanel:
'grid min-h-40 content-center gap-3 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15',
assetOverviewPanel:
'grid min-h-40 grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] items-center gap-x-7 gap-y-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15',
assetOverviewHead: 'grid gap-2',
assetOverviewSubtext: 'text-sm font-semibold text-[#c3c7cf]',
assetBreakdownPanel: 'grid gap-3',
assetBreakdownTitle: 'text-xs font-extrabold text-[#9aa0a9]',
metricLabel: 'text-xs font-extrabold text-[#9aa0a9]',
metricValue: 'text-3xl font-black',
positive: 'text-[#40dba2]',
negative: 'text-[#ffb4ab]',
miniProgress: 'block h-2 w-full overflow-hidden rounded-full bg-[#33383f]',
miniProgressBar: 'block h-full w-[12%] rounded-full bg-[#a8c7fa]',
assetBreakdownBar: 'flex h-3 w-full overflow-hidden rounded-full bg-[#33383f]',
assetBreakdownStocks: 'block h-full bg-[#a8c7fa]',
assetBreakdownCash: 'block h-full bg-[#40dba2]',
assetBreakdownRows: 'grid gap-2',
assetBreakdownRow: 'flex items-center justify-between gap-4',
assetBreakdownLabel: 'inline-flex items-center gap-2 text-sm font-semibold text-[#c3c7cf]',
assetBreakdownSwatch: 'block h-2.5 w-2.5 shrink-0 rounded-full',
assetBreakdownSwatchStocks: 'bg-[#a8c7fa]',
assetBreakdownSwatchCash: 'bg-[#40dba2]',
assetBreakdownMeta: 'grid justify-items-end gap-0.5 text-right',
assetBreakdownAmount: 'text-sm font-black text-[#e3e3e9]',
assetBreakdownRatio: 'text-xs font-semibold text-[#8f949d]',
holdingsPanel:
'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15',
holdingsBody: 'flex min-h-0 flex-1 flex-col overflow-hidden',
holdingsRows: 'grid min-h-0 flex-1 content-start overflow-y-auto',
holdingsHead:
'grid grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 border-b border-[#33383f] py-3 text-xs font-extrabold text-[#8f949d]',
holdingRow:
'grid min-h-16 grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 rounded-2xl bg-transparent px-3 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930]',
typePill: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs text-[#d3e3fd]',
muted: 'text-[#8f949d]',
portfolioHistory:
'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15',
historyList: 'flex min-h-0 flex-1 flex-col overflow-hidden',
historyRows: 'grid min-h-0 flex-1 content-start gap-7 overflow-y-auto',
emptyState: 'flex flex-1 items-center justify-center py-8 text-center',
miniOrder: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3',
tradeLayout: 'trade-layout grid w-full min-h-[calc(100dvh-6rem)] grid-cols-5 items-stretch gap-5',
watchlist:
'order-3 pl-2 col-span-1 grid h-full min-h-[calc(100dvh-6rem)] content-start overflow-y-auto border-l border-[#30343a] bg-[#101418] shadow-none',
watchSearch:
'm-3 inline-flex min-h-11 items-center gap-2 rounded-full bg-[#111418] px-4 text-left font-medium text-[#9aa0a9] outline outline-1 outline-[#33383f] transition hover:bg-[#22272e] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]',
watchRow:
'grid min-h-18 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-l-2 border-transparent bg-transparent px-4 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#d3e3fd]',
watchRowActive: 'border-[#c3c7cf] bg-[#242930]',
centerStack: 'order-1 col-span-3 grid content-start gap-5',
tradeSeparator: 'order-3 hidden',
stockPanel:
'grid min-h-72 content-start gap-4 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none',
stockTitle: 'flex items-start justify-between gap-3',
stockHoldingNote:
'inline-flex w-fit items-center rounded-full border border-[#2f343b] bg-[#111418] px-4 py-2 text-sm font-semibold text-[#c3c7cf]',
quoteBox: 'grid justify-items-end gap-1',
periodTabs: 'flex justify-end gap-3',
periodButton: 'min-h-7 rounded-full bg-transparent px-3 text-xs font-bold text-[#8f949d]',
periodButtonActive: 'bg-[#d3e3fd] text-[#102033]',
chartActions: 'flex items-center justify-between gap-4',
smallTabs: 'grid grid-cols-2 gap-1 rounded-full bg-[#111418] p-1',
smallTab: 'min-h-8 rounded-full bg-transparent px-3 text-xs font-bold text-[#9aa0a9]',
smallTabActive: 'bg-[#263141] text-[#d3e3fd]',
chartBox: 'mt-1 h-40 overflow-hidden rounded-[20px] bg-[#111418]',
chartLine: 'fill-none stroke-[#40dba2] stroke-[3]',
boxplot: 'grid min-h-36 content-center gap-5 rounded-[24px] bg-[#111418] p-7',
boxplotScale: 'flex justify-between text-xs text-[#8f949d]',
boxplotTrack:
'relative h-20 before:absolute before:inset-x-0 before:top-10 before:h-0.5 before:bg-[#4a5058]',
whisker: 'absolute top-9 h-3 border-x-2 border-[#c3c7cf]',
box: 'absolute top-6 h-9 rounded-xl border-2 border-[#a8c7fa] bg-[#a8c7fa]/25',
median: 'absolute top-4 h-12 border-l-4 border-[#fdd663]',
infoTabPanel: 'grid gap-0 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none',
infoTabList: 'flex gap-6 border-b border-[#33383f]',
infoTabButton:
'relative min-h-10 px-1 text-sm font-black text-[#8f949d] transition hover:text-[#d3e3fd] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d3e3fd]',
infoTabButtonActive: 'text-[#d3e3fd]',
infoTabIndicator: 'absolute inset-x-0 bottom-[-1px] block h-0.5 rounded-full bg-[#d3e3fd]',
infoTabBody: 'overflow-hidden pt-4',
detailGrid: 'grid grid-cols-3 gap-3',
detailItem: 'grid min-h-16 gap-1 rounded-[14px] bg-[#111418] p-3',
holdingEmpty:
'rounded-[16px] border border-[#2f343b] bg-[#111418] px-4 py-3 text-sm font-semibold text-[#8f949d]',
ticketPanel:
'order-2 col-span-1 flex h-full min-h-0 flex-col gap-4 overflow-hidden border-0 bg-[#101418] p-6 shadow-none',
ticketTop: 'shrink-0',
ticketScroll: 'min-h-0 flex-1 overflow-y-auto pr-1',
ticketScrollInner: 'grid content-start gap-3',
ticketBottom: 'grid shrink-0 gap-3',
ticketBox: 'grid grid-cols-1 gap-3',
advancedOptions: 'group bg-transparent p-0',
advancedSummary:
'flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 rounded-[16px] bg-[#111418] px-4 text-sm font-extrabold text-[#d3e3fd] transition hover:bg-[#182029] marker:hidden [&::-webkit-details-marker]:hidden',
advancedSummaryIconWrap: 'grid h-4 w-4 place-items-center',
advancedSummaryIcon: 'h-4 w-4',
advancedBody: 'grid gap-2 pt-3',
advancedLabel: 'text-xs font-extrabold text-[#9aa0a9]',
label: 'grid gap-2 text-xs font-extrabold text-[#9aa0a9]',
input:
'min-h-12 w-full rounded-[16px] border border-[#4a5058] bg-[#111418] px-4 text-[#e3e3e9] outline-none transition placeholder:text-[#747982] focus:border-[#a8c7fa]',
estimateSummary:
'flex items-center justify-between rounded-[18px] border border-[#2f343b] bg-[#111418] px-5 py-4',
holdingNote:
'rounded-[16px] border border-[#2f343b] bg-[#111418] px-4 py-3 text-sm font-semibold text-[#c3c7cf]',
actions: 'grid gap-3',
primaryButton:
'inline-flex min-h-10 items-center justify-center gap-2 rounded-full bg-[#a8c7fa] px-5 font-extrabold text-[#102033] shadow-sm shadow-black/20 transition hover:bg-[#d3e3fd] disabled:opacity-50',
dangerButton:
'inline-flex min-h-10 items-center justify-center gap-2 rounded-full bg-[#ffb4ab] px-5 font-extrabold text-[#690005] shadow-sm shadow-black/20 transition hover:bg-[#ffd8d3] disabled:opacity-50',
ghostButton:
'inline-flex min-h-10 items-center justify-center gap-2 rounded-full border border-[#4a5058] bg-transparent px-5 font-extrabold text-[#d3e3fd] transition hover:bg-[#263141]',
list: 'grid gap-2',
orderRow:
'grid grid-cols-[minmax(180px,1fr)_auto_auto_auto_auto_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4',
statusBadge: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs font-black text-[#d3e3fd]',
pendingBadge: 'bg-[#4a3720] text-[#ffddb3]',
apiLayout: 'grid grid-cols-[minmax(0,1fr)_minmax(20rem,0.9fr)] gap-7',
settingsLayout: 'grid gap-7',
row: 'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4',
rowActions: 'flex gap-2',
keyRow: 'grid gap-3 rounded-[24px] bg-[#111418] p-4',
profileRow:
'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4',
searchOverlay: 'fixed inset-0 z-20 bg-[#101418]/75 backdrop-blur-sm',
searchSheet:
'mx-auto mt-4 grid max-h-[85vh] w-[48rem] gap-3 overflow-auto rounded-[28px] border border-[#30343a] bg-[#1b1f24]/85 p-4 shadow-2xl shadow-black/35',
searchInputRow: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3',
filterRow: 'grid grid-cols-2 gap-3',
searchResults: 'grid gap-2',
searchLoading: 'flex min-h-32 flex-col items-center justify-center gap-3 py-8 text-[#9aa0a8]',
searchResult:
'flex min-h-16 items-center justify-between gap-4 rounded-[20px] border border-transparent bg-[#111418] p-4 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]',
searchResultActive: 'border-[#5f666f] bg-[#242930]',
confirmList: 'grid gap-2',
confirmRow: 'flex justify-between gap-3 border-b border-[#33383f] pb-2',
dialogNote: 'leading-7 text-[#c3c7cf]',
} as const
+102
View File
@@ -0,0 +1,102 @@
export type TradeSide = 'buy' | 'sell'
export type OrderKind = 'standard' | 's'
export type CashOrderAccountType = 'specific' | 'general'
export type CashOrderMarket = 'auto' | 'TKY' | 'SOR' | 'PTS' | 'PTX' | 'STK'
export type CashOrderPriceCondition =
| 'limit'
| 'limitAtOpen'
| 'limitAtClose'
| 'limitIoc'
| 'market'
| 'marketAtOpen'
| 'marketAtClose'
| 'marketIoc'
| 'funari'
export type CashOrderTerm = 'day' | 'week' | 'date'
export type CashOrderMethod = 'normal' | 'stop' | 'oco'
export type CashOrderTriggerZone = 'above' | 'below'
export type ChartMode = 'line' | 'box'
export type RpcMessage = {
id: number
method: string
params?: unknown
}
export type OrderPreview = {
issue: {
code: string
market: string
}
side: string
quantity: number
warnings: string[]
confirmationId?: string
message?: string
}
export type JsonRpcResponse = {
id?: number
method?: string
params?: unknown
result?: unknown
error?: {
message?: string
}
}
export type RealtimePricePoint = {
at: string
price: number
}
export type Stock = {
code: string
name: string
symbol: string
country: string
market: string
sector: string
price: number
change: number
changeAmount: number
volume: number
open: number
high: number
low: number
prevClose: number
sShare: boolean
history: number[]
box: {
min: number
q1: number
median: number
q3: number
max: number
}
}
export type OrderRow = {
id: string
date: string
stock: string
side: TradeSide
kind: OrderKind
quantity: number | null
price: number | null
status: '注文中' | '約定済' | '取消済'
orderNumber?: string
tradeId?: string
}
export type Position = {
code: string
name: string
market: string
quantity: number
avgPrice: number
marketValue: number
profitLoss: number
profitLossRate: number
type?: string
}
+14
View File
@@ -0,0 +1,14 @@
export const currency = (value: number) =>
new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY',
maximumFractionDigits: 0,
}).format(value)
export const number = (value: number) => new Intl.NumberFormat('ja-JP').format(value)
export const signedCurrency = (value: number) =>
`${value >= 0 ? '+' : '-'}${currency(Math.abs(value))}`
export const signedPercent = (value: number, fractionDigits = 2) =>
`${value >= 0 ? '+' : ''}${value.toFixed(fractionDigits)}%`
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"jsx": "preserve"
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
const serverPort = Number(process.env.PORT ?? process.env.CSBIE_SERVER_PORT ?? 8787)
const serverOrigin = process.env.CSBIE_SERVER_ORIGIN ?? `http://127.0.0.1:${serverPort}`
const proxyToServer = {
target: serverOrigin,
changeOrigin: true,
ws: true,
}
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
port: Number(process.env.CSBIE_UI_DEV_PORT ?? 5173),
strictPort: true,
hmr: {
host: '127.0.0.1',
clientPort: Number(process.env.CSBIE_UI_DEV_PORT ?? 5173),
},
proxy: {
'/api': {
...proxyToServer,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/.well-known': proxyToServer,
'/authorize': proxyToServer,
'/token': proxyToServer,
'/register': proxyToServer,
'/revoke': proxyToServer,
'/oauth': proxyToServer,
},
},
})
+21
View File
@@ -0,0 +1,21 @@
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock tsconfig.json vite.config.ts ./
COPY packages/sbi-client/package.json packages/sbi-client/package.json
COPY apps/csbie/package.json apps/csbie/package.json
COPY apps/csbie-server/package.json apps/csbie-server/package.json
COPY apps/csbie-ui/package.json apps/csbie-ui/package.json
RUN bun install --frozen-lockfile
FROM deps AS build
COPY . .
RUN bun --filter @repo/csbie build
FROM oven/bun:1-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/csbie/dist ./apps/csbie/dist
COPY --from=build /app/apps/csbie-ui/dist ./apps/csbie-ui/dist
COPY --from=build /app/node_modules ./node_modules
EXPOSE 8787
CMD ["bun", "apps/csbie/dist/index.js"]
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@repo/csbie",
"private": true,
"type": "module",
"scripts": {
"clean": "rm -rf dist ../csbie-ui/dist",
"dev": "bun --env-file=../../.env src/dev.ts",
"build": "bun --filter @repo/csbie-ui build && bun build src/index.ts --target=bun --outdir=dist",
"start": "bun --env-file=../../.env dist/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@repo/csbie-server": "workspace:*",
"hono": "^4.8.3"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5"
}
}
+37
View File
@@ -0,0 +1,37 @@
const uiPort = Number(process.env.CSBIE_UI_DEV_PORT ?? 5173)
const serverPort = Number(process.env.PORT ?? process.env.CSBIE_SERVER_PORT ?? 8787)
const uiOrigin = `http://127.0.0.1:${uiPort}`
const serverEntry = new URL('../../csbie-server/src/index.ts', import.meta.url).pathname
const ui = Bun.spawn({
cmd: ['bun', '--filter', '@repo/csbie-ui', 'dev'],
stdout: 'inherit',
stderr: 'inherit',
stdin: 'inherit',
env: process.env,
})
const server = Bun.spawn({
cmd: ['bun', '--watch', serverEntry],
stdout: 'inherit',
stderr: 'inherit',
stdin: 'inherit',
env: {
...process.env,
PORT: String(serverPort),
CSBIE_ORIGIN: uiOrigin,
CSBIE_CORS_ORIGIN: uiOrigin,
},
})
const shutdown = () => {
ui.kill()
server.kill()
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
const appExit = await server.exited
shutdown()
process.exit(appExit)
+55
View File
@@ -0,0 +1,55 @@
import { existsSync } from 'node:fs'
import { extname, join } from 'node:path'
import { Hono } from 'hono'
import { createServerApp } from '@repo/csbie-server/app'
import { loadConfig } from '@repo/csbie-server/config'
import { createDb } from '@repo/csbie-server/db'
const config = loadConfig()
const db = createDb(config.databasePath)
const api = createServerApp(db, config)
const app = new Hono()
const uiDist = join(import.meta.dir, '../../csbie-ui/dist')
const contentTypes: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
}
app.route('/api', api.app)
app.route('/', api.app)
const serveUi = async (request: Request) => {
const url = new URL(request.url)
const pathname = decodeURIComponent(url.pathname)
const relativePath = pathname === '/' ? 'index.html' : pathname.slice(1)
const filePath = join(uiDist, relativePath)
const resolvedPath = existsSync(filePath) ? filePath : join(uiDist, 'index.html')
const file = Bun.file(resolvedPath)
const headers = new Headers()
headers.set('content-type', contentTypes[extname(resolvedPath)] ?? 'application/octet-stream')
return new Response(file, { headers })
}
const server = Bun.serve({
port: config.port,
fetch(request, server) {
const url = new URL(request.url)
if (
url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/.well-known/') ||
url.pathname.startsWith('/oauth/') ||
['/authorize', '/token', '/register', '/revoke'].includes(url.pathname)
) {
return app.fetch(request, { server })
}
return serveUi(request)
},
websocket: api.websocket,
})
console.log(`csbie listening on http://localhost:${server.port}`)
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"rootDir": "src",
"outDir": "dist",
"noEmit": true
},
"include": ["src/**/*.ts"]
}