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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/commands/slash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,18 +192,18 @@ describe('parseSlashCommand — config commands', () => {
// ─── SLASH_COMMANDS registry ────────────────────────────────────────────────

describe('SLASH_COMMANDS registry', () => {
test('contains all 23 commands', () => {
test('contains all 24 commands', () => {
const expected = [
'/model', '/clear', '/skill', '/help', '/exit',
'/compact', '/context', '/cost', '/resume', '/export',
'/rename', '/rewind', '/status', '/permissions', '/mcp',
'/config', '/todos', '/verbose', '/agents', '/doctor', '/init',
'/output-style', '/style',
'/output-style', '/style', '/portfolio',
];
for (const cmd of expected) {
expect(SLASH_COMMANDS).toHaveProperty(cmd);
}
expect(Object.keys(SLASH_COMMANDS)).toHaveLength(23);
expect(Object.keys(SLASH_COMMANDS)).toHaveLength(24);
});
});

Expand All @@ -229,6 +229,16 @@ describe('parseSlashCommand — output style commands', () => {
});
});

// ─── Portfolio command ──────────────────────────────────────────────────────

describe('parseSlashCommand — portfolio command', () => {
test('/portfolio returns action', () => {
const result = parseSlashCommand('/portfolio');
expect(result?.handled).toBe(true);
expect(result?.action).toBe('portfolio');
});
});

// ─── Case insensitivity & whitespace ────────────────────────────────────────

describe('parseSlashCommand — edge cases', () => {
Expand Down
7 changes: 6 additions & 1 deletion src/commands/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export type SlashAction =
| 'verbose'
| 'doctor'
| 'init'
| 'output-style';
| 'output-style'
| 'portfolio';

export interface SlashCommandResult {
/** Whether the command was recognized and handled */
Expand Down Expand Up @@ -68,6 +69,7 @@ export const SLASH_COMMANDS: Record<string, string> = {
'/init': 'Initialize project (.tino/ directory, settings, permissions)',
'/output-style': 'Switch output style (concise, explanatory, trading, etc.)',
'/style': 'Alias for /output-style',
'/portfolio': 'Show cross-exchange portfolio summary',
};

// ─── Helpers ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -188,6 +190,9 @@ export function parseSlashCommand(input: string): SlashCommandResult | null {
case '/style':
return { handled: true, action: 'output-style' };

case '/portfolio':
return { handled: true, action: 'portfolio' };

default:
// Starts with / but not a recognized command
return { handled: false };
Expand Down
20 changes: 20 additions & 0 deletions src/grpc/exchange-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ import {
type PlaceTrailingStopResponse,
type PlaceStopOrderRequest,
type PlaceStopOrderResponse,
type GetAccountBalanceResponse,
type GetExchangePositionsResponse,
} from "./gen/tino/exchange/v1/exchange_pb.js";
import { GrpcClient, type GrpcClientOptions } from "./client.js";
import { create } from "@bufbuild/protobuf";
import {
PlaceTpSlOrderRequestSchema,
PlaceTrailingStopRequestSchema,
PlaceStopOrderRequestSchema,
GetAccountBalanceRequestSchema,
GetExchangePositionsRequestSchema,
} from "./gen/tino/exchange/v1/exchange_pb.js";

type ExchangeServiceClient = Client<typeof ExchangeService>;
Expand Down Expand Up @@ -47,4 +51,20 @@ export class ExchangeClient extends GrpcClient {
const request = create(PlaceStopOrderRequestSchema, params);
return await this.client.placeStopOrder(request);
}

async getAccountBalance(exchange: string): Promise<GetAccountBalanceResponse> {
const request = create(GetAccountBalanceRequestSchema, { exchange });
return await this.client.getAccountBalance(request);
}

async getExchangePositions(
exchange: string,
symbol?: string,
): Promise<GetExchangePositionsResponse> {
const request = create(GetExchangePositionsRequestSchema, {
exchange,
symbol: symbol ?? "",
});
return await this.client.getExchangePositions(request);
}
}
1 change: 1 addition & 0 deletions src/hooks/slash-command-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export async function runExtendedSlashAction(
case 'exit':
case 'verbose':
case 'output-style':
case 'portfolio':
return null;
}
}
194 changes: 194 additions & 0 deletions src/tools/consolidated/__tests__/portfolio.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, test, expect, afterEach } from 'bun:test';
import type { ToolContext } from '@/domain/tool-plugin.js';
import type { PortfolioClient } from '@/grpc/portfolio-client.js';
import type { ExchangeClient } from '@/grpc/exchange-client.js';
import type { GrpcClients } from '@/domain/tool-plugin.js';

function makeCtx(overrides: Partial<ToolContext> = {}): ToolContext {
return {
Expand All @@ -11,6 +13,12 @@ function makeCtx(overrides: Partial<ToolContext> = {}): ToolContext {
};
}

function makeCtxWithExchange(exchangeClient: ExchangeClient): ToolContext {
return makeCtx({
grpc: { exchange: exchangeClient } as unknown as GrpcClients,
});
}

async function getSetClients() {
const mod = await import('../../portfolio/grpc-clients.js');
return mod.__setClients;
Expand Down Expand Up @@ -170,4 +178,190 @@ describe('portfolio consolidated tool', () => {
expect(parsed.data.entries[1].totalPnl).toBe(400.0);
});
});

describe('cross_exchange_summary action', () => {
test('aggregates balances across exchanges', async () => {
const mockExchangeClient = {
getAccountBalance: async (exchange: string) => {
if (exchange === 'binance') {
return {
balances: [
{ asset: 'USDT', free: 5000, locked: 1000, total: 6000 },
{ asset: 'BTC', free: 0.5, locked: 0, total: 0.5 },
],
};
}
if (exchange === 'okx') {
return {
balances: [
{ asset: 'USDT', free: 3000, locked: 500, total: 3500 },
{ asset: 'ETH', free: 2.0, locked: 0, total: 2.0 },
],
};
}
return { balances: [] };
},
} as unknown as ExchangeClient;

const mod = await import('../../consolidated/portfolio.tool.js');
const result = await mod.default.execute(
{ action: 'cross_exchange_summary' },
makeCtxWithExchange(mockExchangeClient),
);

const parsed = JSON.parse(result);
expect(parsed.data.exchangesQueried).toBe(4);
expect(parsed.data.exchangesSucceeded).toBe(4);
expect(parsed.data.totalUsdtValue).toBe(9500);
expect(parsed.data.exchangeBalances).toHaveLength(4);

const binance = parsed.data.exchangeBalances.find(
(e: { exchange: string }) => e.exchange === 'binance',
);
expect(binance.totalUsdtValue).toBe(6000);

const usdtAgg = parsed.data.aggregatedAssets.find(
(a: { asset: string }) => a.asset === 'USDT',
);
expect(usdtAgg.total).toBe(9500);

expect(parsed.data.distribution).toHaveLength(4);
const binanceDist = parsed.data.distribution.find(
(d: { exchange: string }) => d.exchange === 'binance',
);
expect(binanceDist.percentage).toBeCloseTo(63.16, 1);
});

test('handles exchange errors gracefully', async () => {
const mockExchangeClient = {
getAccountBalance: async (exchange: string) => {
if (exchange === 'binance') {
return {
balances: [
{ asset: 'USDT', free: 10000, locked: 0, total: 10000 },
],
};
}
throw new Error(`${exchange} API key not configured`);
},
} as unknown as ExchangeClient;

const mod = await import('../../consolidated/portfolio.tool.js');
const result = await mod.default.execute(
{ action: 'cross_exchange_summary' },
makeCtxWithExchange(mockExchangeClient),
);

const parsed = JSON.parse(result);
expect(parsed.data.exchangesSucceeded).toBe(1);
expect(parsed.data.errors).toHaveLength(3);
expect(parsed.data.totalUsdtValue).toBe(10000);
});
});

describe('cross_exchange_positions action', () => {
test('aggregates positions across exchanges', async () => {
const mockExchangeClient = {
getExchangePositions: async (exchange: string) => {
if (exchange === 'binance') {
return {
positions: [
{
symbol: 'BTCUSDT',
side: 'LONG',
quantity: 0.1,
entryPrice: 60000,
unrealizedPnl: 500,
leverage: 10,
markPrice: 65000,
liquidationPrice: 54000,
marginType: 'cross',
},
],
};
}
if (exchange === 'bybit') {
return {
positions: [
{
symbol: 'ETHUSDT',
side: 'SHORT',
quantity: 1.0,
entryPrice: 3500,
unrealizedPnl: -100,
leverage: 5,
markPrice: 3600,
liquidationPrice: 4200,
marginType: 'isolated',
},
],
};
}
return { positions: [] };
},
} as unknown as ExchangeClient;

const mod = await import('../../consolidated/portfolio.tool.js');
const result = await mod.default.execute(
{ action: 'cross_exchange_positions' },
makeCtxWithExchange(mockExchangeClient),
);

const parsed = JSON.parse(result);
expect(parsed.data.totalPositions).toBe(2);
expect(parsed.data.totalUnrealizedPnl).toBe(400);
expect(parsed.data.exchangesQueried).toBe(4);
expect(parsed.data.exchangesSucceeded).toBe(4);

const btcPos = parsed.data.positions.find(
(p: { symbol: string }) => p.symbol === 'BTCUSDT',
);
expect(btcPos.exchange).toBe('binance');
expect(btcPos.side).toBe('LONG');
expect(btcPos.unrealizedPnl).toBe(500);

const ethPos = parsed.data.positions.find(
(p: { symbol: string }) => p.symbol === 'ETHUSDT',
);
expect(ethPos.exchange).toBe('bybit');
expect(ethPos.side).toBe('SHORT');
});

test('handles exchange errors gracefully', async () => {
const mockExchangeClient = {
getExchangePositions: async (exchange: string) => {
if (exchange === 'okx') {
return {
positions: [
{
symbol: 'BTCUSDT',
side: 'LONG',
quantity: 0.5,
entryPrice: 62000,
unrealizedPnl: 1500,
leverage: 3,
markPrice: 65000,
liquidationPrice: 42000,
marginType: 'cross',
},
],
};
}
throw new Error(`${exchange} connection failed`);
},
} as unknown as ExchangeClient;

const mod = await import('../../consolidated/portfolio.tool.js');
const result = await mod.default.execute(
{ action: 'cross_exchange_positions' },
makeCtxWithExchange(mockExchangeClient),
);

const parsed = JSON.parse(result);
expect(parsed.data.totalPositions).toBe(1);
expect(parsed.data.totalUnrealizedPnl).toBe(1500);
expect(parsed.data.exchangesSucceeded).toBe(1);
expect(parsed.data.errors).toHaveLength(3);
});
});
});
Loading
Loading