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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dependencies": {
"@coin/database": "workspace:*",
"@coin/exchange-adapters": "workspace:*",
"@coin/indicators": "workspace:*",
"@coin/kafka-contracts": "workspace:*",
"@coin/types": "workspace:*",
"@coin/utils": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion apps/api-server/src/llm-trades/llm-trades.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { ClaudeTokensModule } from '../claude-tokens/claude-tokens.module';
import { LlmModule } from '../llm/llm.module';
import { LlmTradesController } from './llm-trades.controller';
import { LlmTradesService } from './llm-trades.service';
import { MarketContextService } from './market-context/market-context.service';

@Module({
imports: [PrismaModule, ClaudeTokensModule, LlmModule],
controllers: [LlmTradesController],
providers: [LlmTradesService],
providers: [LlmTradesService, MarketContextService],
})
export class LlmTradesModule {}
26 changes: 14 additions & 12 deletions apps/api-server/src/llm-trades/llm-trades.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { Kafka, Producer } from 'kafkajs';
import { KAFKA_TOPICS } from '@coin/kafka-contracts';
import type { OrderRequestedEvent } from '@coin/kafka-contracts';
import { BinanceRest } from '@coin/exchange-adapters';
import type { Candle } from '@coin/types';
import { randomUUID } from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
import { ClaudeTokensService } from '../claude-tokens/claude-tokens.service';
import { LlmCliService, LlmDecision } from '../llm/llm-cli.service';
import { MarketContextService } from './market-context/market-context.service';
import type { MarketContext } from './market-context/market-context.types';
import { RequestSignalDto } from './dto/request-signal.dto';
import { ExecuteTradeDto } from './dto/execute-trade.dto';

Expand All @@ -23,6 +24,7 @@ export class LlmTradesService {
private readonly prisma: PrismaService,
private readonly tokens: ClaudeTokensService,
private readonly llm: LlmCliService,
private readonly marketContext: MarketContextService,
) {
this.kafka = new Kafka({
clientId: 'api-llm-trades',
Expand All @@ -43,24 +45,24 @@ export class LlmTradesService {
async signal(
userId: string,
dto: RequestSignalDto,
): Promise<LlmDecision & { entryPrice: string; candles: Candle[] }> {
): Promise<LlmDecision & { entryPrice: string; marketContext: MarketContext }> {
const oauthToken = await this.tokens.getDecrypted(userId);
const candles = await this.binance.getCandles(dto.symbol, dto.interval, dto.candleCount);
if (candles.length === 0) {
throw new BadRequestException(`No candles for ${dto.symbol} @ ${dto.interval}`);
}
const decision = await this.llm.decide({
oauthToken,
const marketContext = await this.marketContext.build({
symbol: dto.symbol,
interval: dto.interval,
candles,
promptCandleCount: dto.candleCount,
});
const entryPrice = candles[candles.length - 1].close;
if (marketContext.candles.length === 0) {
throw new BadRequestException(`No candles for ${dto.symbol} @ ${dto.interval}`);
}
const decision = await this.llm.decide({ oauthToken, marketContext });
const lastCandle = marketContext.candles[marketContext.candles.length - 1];
const entryPrice = lastCandle.c;

await this.prisma.llmDecisionLog.create({
data: {
userId,
prompt: `${dto.symbol} ${dto.interval} ${dto.candleCount}`,
prompt: `${dto.symbol} ${dto.interval} candles=${marketContext.candles.length} indicators+sentiment`,
rawResponse: decision.rawResponse,
parsedSignal: {
signal: decision.signal,
Expand All @@ -73,7 +75,7 @@ export class LlmTradesService {
},
});

return { ...decision, entryPrice, candles };
return { ...decision, entryPrice, marketContext };
}

async execute(userId: string, dto: ExecuteTradeDto): Promise<{ id: string; status: string }> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Candle, FundingRateRecord, OpenInterestPoint } from '@coin/types';
import { buildMarketContext, formatTimestamp, type BuildDeps } from './build-market-context';

function mkCandles(n: number, baseTime = 1700000000000): Candle[] {
return Array.from({ length: n }, (_, i) => {
const close = 60_000 + Math.sin(i / 5) * 100 + i;
return {
exchange: 'binance' as const,
symbol: 'BTCUSDT',
interval: '1h',
open: String(close - 1),
high: String(close + 5),
low: String(close - 5),
close: String(close),
volume: '10',
timestamp: baseTime + i * 60 * 60_000,
};
});
}

function makeDeps(overrides: Partial<BuildDeps> = {}): BuildDeps {
return {
fetchCandles: vi.fn().mockResolvedValue(mkCandles(250)),
fetchFundingHistory: vi.fn().mockResolvedValue([
{ symbol: 'BTCUSDT', fundingTime: Date.now() - 8 * 3600_000, fundingRate: '0.0001' },
{ symbol: 'BTCUSDT', fundingTime: Date.now(), fundingRate: '0.00015' },
] satisfies FundingRateRecord[]),
fetchCurrentFunding: vi
.fn()
.mockResolvedValue({
symbol: 'BTCUSDT',
lastFundingRate: '0.00012',
nextFundingTime: Date.now() + 3600_000,
}),
fetchOpenInterestHistory: vi.fn().mockResolvedValue([
{
symbol: 'BTCUSDT',
sumOpenInterest: '1000',
sumOpenInterestValueUsdt: '60000000',
timestamp: Date.now() - 24 * 3600_000,
},
{
symbol: 'BTCUSDT',
sumOpenInterest: '1100',
sumOpenInterestValueUsdt: '66000000',
timestamp: Date.now(),
},
] satisfies OpenInterestPoint[]),
...overrides,
};
}

describe('buildMarketContext', () => {
beforeEach(() => vi.clearAllMocks());

it('takes only the last promptCandleCount enriched rows', async () => {
const deps = makeDeps();
const ctx = await buildMarketContext(
{ symbol: 'BTCUSDT', interval: '1h', promptCandleCount: 60 },
deps,
);
expect(ctx.candles.length).toBe(60);
expect(ctx.candles[0].t).toBeTruthy();
expect(ctx.candles[59].ema200).not.toBeNull(); // last row past EMA200 warmup
});

it('always fetches at least 220 candles regardless of prompt count', async () => {
const deps = makeDeps();
await buildMarketContext({ symbol: 'BTCUSDT', interval: '1h', promptCandleCount: 30 }, deps);
expect(deps.fetchCandles).toHaveBeenCalledWith('BTCUSDT', '1h', expect.any(Number));
const callArgs = (deps.fetchCandles as ReturnType<typeof vi.fn>).mock.calls[0];
expect(callArgs[2]).toBeGreaterThanOrEqual(220);
});

it('throws on empty candles', async () => {
const deps = makeDeps({ fetchCandles: vi.fn().mockResolvedValue([]) });
await expect(
buildMarketContext({ symbol: 'BTCUSDT', interval: '1h', promptCandleCount: 60 }, deps),
).rejects.toThrow();
});

it('returns null sentiment fields when adapters throw — degraded mode', async () => {
const deps = makeDeps({
fetchFundingHistory: vi.fn().mockRejectedValue(new Error('503')),
fetchCurrentFunding: vi.fn().mockRejectedValue(new Error('503')),
fetchOpenInterestHistory: vi.fn().mockRejectedValue(new Error('503')),
});
const ctx = await buildMarketContext(
{ symbol: 'BTCUSDT', interval: '1h', promptCandleCount: 60 },
deps,
);
expect(ctx.sentiment.fundingRate).toBeNull();
expect(ctx.sentiment.openInterest).toBeNull();
// Candles still present
expect(ctx.candles.length).toBe(60);
});

it('structure has swing high/low and atrPct after enough candles', async () => {
const deps = makeDeps();
const ctx = await buildMarketContext(
{ symbol: 'BTCUSDT', interval: '1h', promptCandleCount: 60 },
deps,
);
expect(ctx.structure.swingHigh).not.toBeNull();
expect(ctx.structure.swingLow).not.toBeNull();
expect(ctx.structure.swingHigh).toBeGreaterThan(ctx.structure.swingLow!);
expect(ctx.structure.atrPct).toBeGreaterThan(0);
expect(ctx.structure.suggestedSlPct).toBeGreaterThan(0);
});

it('OI 24h change is positive when end > start', async () => {
const deps = makeDeps();
const ctx = await buildMarketContext(
{ symbol: 'BTCUSDT', interval: '1h', promptCandleCount: 60 },
deps,
);
expect(ctx.sentiment.openInterest?.change24hPct).toBeCloseTo(10, 0);
});
});

describe('formatTimestamp', () => {
const t = Date.UTC(2026, 4, 2, 15, 23, 7); // 2026-05-02T15:23:07Z

it('1m → minute precision', () => {
expect(formatTimestamp(t, '1m')).toBe('2026-05-02T15:23:00Z');
});
it('1h → hour precision', () => {
expect(formatTimestamp(t, '1h')).toBe('2026-05-02T15:00:00Z');
});
it('1d → date only', () => {
expect(formatTimestamp(t, '1d')).toBe('2026-05-02');
});
});
Loading
Loading