diff --git a/apps/api-server/package.json b/apps/api-server/package.json index 1e0c494..cb53ff6 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -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:*", diff --git a/apps/api-server/src/llm-trades/llm-trades.module.ts b/apps/api-server/src/llm-trades/llm-trades.module.ts index 5a2e755..d87a052 100644 --- a/apps/api-server/src/llm-trades/llm-trades.module.ts +++ b/apps/api-server/src/llm-trades/llm-trades.module.ts @@ -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 {} diff --git a/apps/api-server/src/llm-trades/llm-trades.service.ts b/apps/api-server/src/llm-trades/llm-trades.service.ts index 84dd7e6..a61d117 100644 --- a/apps/api-server/src/llm-trades/llm-trades.service.ts +++ b/apps/api-server/src/llm-trades/llm-trades.service.ts @@ -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'; @@ -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', @@ -43,24 +45,24 @@ export class LlmTradesService { async signal( userId: string, dto: RequestSignalDto, - ): Promise { + ): Promise { 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, @@ -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 }> { diff --git a/apps/api-server/src/llm-trades/market-context/build-market-context.test.ts b/apps/api-server/src/llm-trades/market-context/build-market-context.test.ts new file mode 100644 index 0000000..a0e0d20 --- /dev/null +++ b/apps/api-server/src/llm-trades/market-context/build-market-context.test.ts @@ -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 { + 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).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'); + }); +}); diff --git a/apps/api-server/src/llm-trades/market-context/build-market-context.ts b/apps/api-server/src/llm-trades/market-context/build-market-context.ts new file mode 100644 index 0000000..197fac9 --- /dev/null +++ b/apps/api-server/src/llm-trades/market-context/build-market-context.ts @@ -0,0 +1,231 @@ +import type { Candle, IndicatorRow, FundingRateRecord, OpenInterestPoint } from '@coin/types'; +import { computeIndicators } from '@coin/indicators'; +import type { + EnrichedCandleRow, + MarketContext, + StructureSummary, + SentimentSummary, + FundingSentiment, + OpenInterestSentiment, +} from './market-context.types'; + +/** Backend always fetches at least this many candles so EMA200 is stable. */ +export const MIN_FETCH_COUNT = 220; + +export interface BuildDeps { + fetchCandles(symbol: string, interval: string, count: number): Promise; + fetchFundingHistory(symbol: string, limit: number): Promise; + fetchCurrentFunding( + symbol: string, + ): Promise<{ symbol: string; lastFundingRate: string; nextFundingTime: number }>; + fetchOpenInterestHistory( + symbol: string, + period: '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '12h' | '1d', + limit: number, + ): Promise; +} + +export interface BuildOptions { + symbol: string; + interval: string; + promptCandleCount: number; +} + +/** + * Build a complete MarketContext for an LLM signal request. Pure modulo deps: + * 1. Fetch enough candles to compute EMA200. + * 2. Compute indicator series, take last `promptCandleCount` rows. + * 3. Derive structure (swing high/low, ATR%, suggested SL%) from those rows. + * 4. Fetch funding/OI in parallel; tolerate failures (degraded mode → null). + * + * The function never throws on sentiment failure — degraded mode lets the + * signal proceed without it. Only candle-fetch errors propagate. + */ +export async function buildMarketContext( + opts: BuildOptions, + deps: BuildDeps, +): Promise { + const { symbol, interval, promptCandleCount } = opts; + const fetchCount = Math.max(MIN_FETCH_COUNT, promptCandleCount + 50); + + const candles = await deps.fetchCandles(symbol, interval, fetchCount); + if (candles.length === 0) { + throw new Error(`No candles for ${symbol} @ ${interval}`); + } + + const series = computeIndicators(candles); + const startIdx = Math.max(0, candles.length - promptCandleCount); + const promptCandles = candles.slice(startIdx); + const promptRows = series.rows.slice(startIdx); + + const enriched: EnrichedCandleRow[] = promptCandles.map((c, i) => ({ + t: formatTimestamp(c.timestamp, interval), + o: c.open, + h: c.high, + l: c.low, + c: c.close, + v: c.volume, + ...promptRows[i], + })); + + const structure = computeStructure(enriched, promptRows); + + const sentiment = await fetchSentiment(symbol, interval, deps); + + return { symbol, interval, candles: enriched, structure, sentiment }; +} + +// ── Internals ────────────────────────────────────────────────────── + +const PRECISION_BY_INTERVAL: Record = { + '1m': 'minute', + '3m': 'minute', + '5m': 'minute', + '15m': 'minute', + '30m': 'minute', + '1h': 'hour', + '2h': 'hour', + '4h': 'hour', + '6h': 'hour', + '8h': 'hour', + '12h': 'hour', + '1d': 'day', + '3d': 'day', + '1w': 'day', +}; + +/** ISO 8601 truncated to the given interval's precision. */ +export function formatTimestamp(epochMs: number, interval: string): string { + const d = new Date(epochMs); + const iso = d.toISOString(); // 2026-05-02T15:23:00.000Z + const precision = PRECISION_BY_INTERVAL[interval] ?? 'minute'; + if (precision === 'day') return iso.slice(0, 10); // 2026-05-02 + if (precision === 'hour') return iso.slice(0, 13) + ':00:00Z'; // 2026-05-02T15:00:00Z + return iso.slice(0, 17) + '00Z'; // 2026-05-02T15:23:00Z +} + +function computeStructure(enriched: EnrichedCandleRow[], rows: IndicatorRow[]): StructureSummary { + let swingHigh: number | null = null; + let swingHighAt: string | null = null; + let swingLow: number | null = null; + let swingLowAt: string | null = null; + for (const row of enriched) { + const high = Number(row.h); + const low = Number(row.l); + if (swingHigh == null || high > swingHigh) { + swingHigh = high; + swingHighAt = row.t; + } + if (swingLow == null || low < swingLow) { + swingLow = low; + swingLowAt = row.t; + } + } + + const lastRow = enriched[enriched.length - 1]; + const lastClose = lastRow ? Number(lastRow.c) : 0; + const lastAtr = rows[rows.length - 1]?.atr14 ?? null; + + let atrPct: number | null = null; + let suggestedSlPct: number | null = null; + if (lastAtr != null && lastClose > 0) { + atrPct = (lastAtr / lastClose) * 100; + suggestedSlPct = (1.5 * lastAtr) / lastClose; // 1.5× ATR as SL distance default + } + + return { swingHigh, swingHighAt, swingLow, swingLowAt, atrPct, suggestedSlPct }; +} + +async function fetchSentiment( + symbol: string, + interval: string, + deps: BuildDeps, +): Promise { + const fundingP = fetchFundingSafely(symbol, deps); + const oiP = fetchOpenInterestSafely(symbol, interval, deps); + const [fundingRate, openInterest] = await Promise.all([fundingP, oiP]); + return { fundingRate, openInterest }; +} + +async function fetchFundingSafely( + symbol: string, + deps: BuildDeps, +): Promise { + try { + const [history, current] = await Promise.all([ + deps.fetchFundingHistory(symbol, 8), + deps.fetchCurrentFunding(symbol), + ]); + const lastSettlements = history.map((h) => ({ + t: new Date(h.fundingTime).toISOString().slice(0, 19) + 'Z', + rate: h.fundingRate, + })); + const avg7d = computeAvg7d(history); + return { + current: current.lastFundingRate, + nextFundingTime: current.nextFundingTime, + lastSettlements, + avg7d, + }; + } catch { + return null; + } +} + +function computeAvg7d(history: FundingRateRecord[]): number | null { + // Funding settles every 8h → 21 settlements per 7 days. + const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; + const recent = history.filter((h) => h.fundingTime >= cutoff); + if (recent.length === 0) return null; + const sum = recent.reduce((s, r) => s + Number(r.fundingRate), 0); + return sum / recent.length; +} + +async function fetchOpenInterestSafely( + symbol: string, + interval: string, + deps: BuildDeps, +): Promise { + // OI history granularity scales with the user's candle interval, capped at 1h + // to keep the series readable (24 points = 24h window at 1h granularity). + const period = oiPeriodFor(interval); + try { + const history = await deps.fetchOpenInterestHistory(symbol, period, 24); + if (history.length === 0) return { currentUsdt: null, change24hPct: null, history: [] }; + const first = Number(history[0].sumOpenInterestValueUsdt); + const last = Number(history[history.length - 1].sumOpenInterestValueUsdt); + const change24hPct = first > 0 ? ((last - first) / first) * 100 : null; + return { + currentUsdt: last, + change24hPct, + history: history.map((p) => ({ + t: new Date(p.timestamp).toISOString().slice(0, 19) + 'Z', + oi: Number(p.sumOpenInterestValueUsdt), + })), + }; + } catch { + return null; + } +} + +function oiPeriodFor( + interval: string, +): '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '12h' | '1d' { + switch (interval) { + case '1m': + case '3m': + case '5m': + return '5m'; + case '15m': + return '15m'; + case '30m': + return '30m'; + case '1h': + case '2h': + return '1h'; + case '4h': + return '4h'; + default: + return '1h'; + } +} diff --git a/apps/api-server/src/llm-trades/market-context/market-context.service.ts b/apps/api-server/src/llm-trades/market-context/market-context.service.ts new file mode 100644 index 0000000..605d34d --- /dev/null +++ b/apps/api-server/src/llm-trades/market-context/market-context.service.ts @@ -0,0 +1,66 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import Redis from 'ioredis'; +import { BinanceRest } from '@coin/exchange-adapters'; +import type { FundingRateRecord, OpenInterestPoint } from '@coin/types'; +import { buildMarketContext, type BuildDeps } from './build-market-context'; +import type { MarketContext } from './market-context.types'; + +const FUNDING_TTL_SEC = 3600; // 1h — funding settles every 8h +const OI_TTL_SEC = 60; // 1min — OI moves continuously + +@Injectable() +export class MarketContextService implements OnModuleDestroy { + private readonly logger = new Logger(MarketContextService.name); + private readonly redis: Redis; + private readonly binance = new BinanceRest(); + + constructor() { + this.redis = new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT || 6379), + }); + } + + async onModuleDestroy() { + this.redis.disconnect(); + } + + build(opts: { + symbol: string; + interval: string; + promptCandleCount: number; + }): Promise { + const deps: BuildDeps = { + fetchCandles: (s, i, n) => this.binance.getCandles(s, i, n), + fetchFundingHistory: (s, n) => + this.cached(`funding:history:${s}`, FUNDING_TTL_SEC, () => + this.binance.getFundingRateHistory(s, n), + ) as Promise, + fetchCurrentFunding: (s) => + this.cached(`funding:current:${s}`, FUNDING_TTL_SEC, () => + this.binance.getCurrentFundingRate(s), + ) as Promise<{ symbol: string; lastFundingRate: string; nextFundingTime: number }>, + fetchOpenInterestHistory: (s, p, n) => + this.cached(`oi:history:${s}:${p}`, OI_TTL_SEC, () => + this.binance.getOpenInterestHistory(s, p, n), + ) as Promise, + }; + return buildMarketContext(opts, deps); + } + + private async cached(key: string, ttlSec: number, fetcher: () => Promise): Promise { + try { + const hit = await this.redis.get(key); + if (hit) return JSON.parse(hit) as T; + } catch (err) { + this.logger.warn(`Redis read failed for ${key}: ${err}`); + } + const value = await fetcher(); + try { + await this.redis.set(key, JSON.stringify(value), 'EX', ttlSec); + } catch (err) { + this.logger.warn(`Redis write failed for ${key}: ${err}`); + } + return value; + } +} diff --git a/apps/api-server/src/llm-trades/market-context/market-context.types.ts b/apps/api-server/src/llm-trades/market-context/market-context.types.ts new file mode 100644 index 0000000..5f545f5 --- /dev/null +++ b/apps/api-server/src/llm-trades/market-context/market-context.types.ts @@ -0,0 +1,48 @@ +import type { IndicatorRow } from '@coin/types'; + +export interface EnrichedCandleRow extends IndicatorRow { + /** ISO 8601 truncated to interval precision. */ + t: string; + o: string; + h: string; + l: string; + c: string; + v: string; +} + +export interface StructureSummary { + swingHigh: number | null; + swingHighAt: string | null; + swingLow: number | null; + swingLowAt: string | null; + /** Last ATR value as a percentage of last close. Useful for "X% volatility right now". */ + atrPct: number | null; + /** Suggested SL distance as a fraction of entry (e.g., 0.015 for 1.5%). */ + suggestedSlPct: number | null; +} + +export interface FundingSentiment { + current: string | null; + nextFundingTime: number | null; + lastSettlements: Array<{ t: string; rate: string }>; + avg7d: number | null; +} + +export interface OpenInterestSentiment { + currentUsdt: number | null; + change24hPct: number | null; + history: Array<{ t: string; oi: number }>; +} + +export interface SentimentSummary { + fundingRate: FundingSentiment | null; + openInterest: OpenInterestSentiment | null; +} + +export interface MarketContext { + symbol: string; + interval: string; + candles: EnrichedCandleRow[]; + structure: StructureSummary; + sentiment: SentimentSummary; +} diff --git a/apps/api-server/src/llm/llm-cli.service.ts b/apps/api-server/src/llm/llm-cli.service.ts index 2d52333..5f7b0d1 100644 --- a/apps/api-server/src/llm/llm-cli.service.ts +++ b/apps/api-server/src/llm/llm-cli.service.ts @@ -1,13 +1,11 @@ import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -import type { Candle } from '@coin/types'; import { runClaudeCli } from './cli-runner'; import { TRADING_SYSTEM_PROMPT } from './prompts/trading-system'; +import type { MarketContext } from '../llm-trades/market-context/market-context.types'; export interface LlmDecisionInput { oauthToken: string; - symbol: string; - interval: string; - candles: Candle[]; + marketContext: MarketContext; } export interface LlmDecision { @@ -87,9 +85,9 @@ export class LlmCliService { throw new Error(`claude cli exit ${cli.exitCode}: ${cli.stderr.slice(0, 500)}`); } - const decision = this.parse(cli.stdout, input.candles); + const decision = this.parse(cli.stdout, input.marketContext); this.logger.log( - `LLM decision for ${input.symbol}: ${decision.signal} tp=${decision.takeProfitPrice} sl=${decision.stopLossPrice} (${cli.durationMs}ms)`, + `LLM decision for ${input.marketContext.symbol}: ${decision.signal} tp=${decision.takeProfitPrice} sl=${decision.stopLossPrice} (${cli.durationMs}ms)`, ); return { ...decision, @@ -106,25 +104,12 @@ export class LlmCliService { } private buildUserPrompt(input: LlmDecisionInput): string { - const compact = input.candles.map((c) => ({ - t: c.timestamp, - o: c.open, - h: c.high, - l: c.low, - c: c.close, - v: c.volume, - })); - return [ - `Symbol: ${input.symbol}`, - `Interval: ${input.interval}`, - `Candles (${input.candles.length}, oldest → newest):`, - JSON.stringify(compact), - ].join('\n'); + return JSON.stringify(input.marketContext); } private parse( cliStdout: string, - candles: Candle[], + marketContext: MarketContext, ): Omit { let envelope: { result?: string }; try { @@ -159,7 +144,8 @@ export class LlmCliService { const tp = Number(signal.takeProfitPrice); const sl = Number(signal.stopLossPrice); - const lastClose = Number(candles[candles.length - 1].close); + const lastCandle = marketContext.candles[marketContext.candles.length - 1]; + const lastClose = Number(lastCandle?.c); if (!Number.isFinite(tp) || !Number.isFinite(sl) || !Number.isFinite(lastClose)) { throw new BadRequestException('LLM returned non-numeric prices'); } diff --git a/apps/api-server/src/llm/prompts/trading-system.ts b/apps/api-server/src/llm/prompts/trading-system.ts index 0b12f28..33ee7f4 100644 --- a/apps/api-server/src/llm/prompts/trading-system.ts +++ b/apps/api-server/src/llm/prompts/trading-system.ts @@ -1,20 +1,43 @@ export const TRADING_SYSTEM_PROMPT = `You are a focused crypto futures trading analyst. -You will be given the last N candles for a single Binance USDT-M perpetual symbol (timeframe specified in the user message). Your job is to decide a single directional position (long or short) and recommend take-profit (TP) and stop-loss (SL) prices in absolute USDT. +You will be given a JSON object describing a single Binance USDT-M perpetual symbol with three layers of context: + +1. \`candles\` — array of recent OHLCV candles, oldest → newest, with technical indicators inlined per row. Each row's keys: + - \`t\` — ISO 8601 timestamp truncated to the candle interval's precision (1m → :00:00Z, 1h → :00:00Z, 1d → date only) + - \`o\`, \`h\`, \`l\`, \`c\`, \`v\` — open, high, low, close, volume (strings, parse to numbers) + - \`ema20\`, \`ema50\`, \`ema200\` — exponential moving averages (number or null during warmup) + - \`rsi14\` — relative strength index, 0-100 (number or null) + - \`bbUpper\`, \`bbMiddle\`, \`bbLower\` — Bollinger Bands (period 20, 2σ) + - \`macd\`, \`macdSignal\`, \`macdHistogram\` — MACD(12, 26, 9) + - \`atr14\` — Average True Range (price units) +2. \`structure\` — derived from the candle window: + - \`swingHigh\`, \`swingHighAt\`, \`swingLow\`, \`swingLowAt\` — extreme high/low and the timestamps they occurred + - \`atrPct\` — last ATR as % of last close (volatility magnitude) + - \`suggestedSlPct\` — 1.5× ATR / lastClose, a sensible default SL distance +3. \`sentiment\` — futures-specific market positioning: + - \`fundingRate.current\` — projected next-settlement rate (string) + - \`fundingRate.lastSettlements\` — last 8 actual settlements (every 8h) + - \`fundingRate.avg7d\` — 7-day mean rate (number or null) + - \`openInterest.currentUsdt\` — current OI in USDT + - \`openInterest.change24hPct\` — 24h % change in OI + - \`openInterest.history\` — 24-point time series of OI in USDT + +\`null\` in any field means data was not available (warmup, fetch failure, or new symbol). Do NOT guess; treat null as missing and weight other evidence accordingly. + +Your job: decide a single directional position (long or short) and recommend take-profit (TP) and stop-loss (SL) prices in absolute USDT. Hard rules — your reply must be **exactly one JSON object on a single line**, no prose, no markdown fences: {"signal":"long|short","takeProfitPrice":"","stopLossPrice":"","reasoning":""} Validation requirements (you MUST satisfy): - - For signal=long: stopLossPrice < lastClose < takeProfitPrice. - For signal=short: takeProfitPrice < lastClose < stopLossPrice. -- TP and SL must be plausible relative to recent volatility — do not propose targets > 10% away from lastClose unless the candles strongly justify it. +- TP and SL must be plausible relative to recent volatility — calibrate to \`structure.atrPct\` and \`structure.suggestedSlPct\`. Do not propose targets > 10% away from lastClose unless the indicators or structure strongly justify it. - Round prices to 2 decimal places when lastClose ≥ 100, else 4 decimal places. -- reasoning ≤ 140 chars. Reference an observable feature (trend, support, recent breakout). Do not say "AI" or "I think". +- \`reasoning\` ≤ 140 chars. Reference an observable feature (RSI level, EMA alignment, BB position, swing point, funding skew, OI surge). Do not say "AI" or "I think". -If the data is too noisy or contradictory to take a side with confidence, still output your best guess and put a hedge in reasoning (e.g. "low conviction; tight TP/SL"). +If signals contradict (e.g., RSI overbought but breaking out above resistance with strong OI inflow), still pick a side and put the hedge in reasoning ("low conviction; tight TP/SL"). Output the JSON object and nothing else. `; diff --git a/apps/web/src/components/llm-trade/llm-trade-form.tsx b/apps/web/src/components/llm-trade/llm-trade-form.tsx index 25f0ead..2661fec 100644 --- a/apps/web/src/components/llm-trade/llm-trade-form.tsx +++ b/apps/web/src/components/llm-trade/llm-trade-form.tsx @@ -38,7 +38,7 @@ function formatPct(p: number | null): string { export function LlmTradeForm() { const [symbol, setSymbol] = useState<(typeof TOP_SYMBOLS)[number]>('BTCUSDT'); const [interval, setInterval] = useState<(typeof INTERVALS)[number]>('5m'); - const [candleCount, setCandleCount] = useState(50); + const [candleCount, setCandleCount] = useState(60); const [network, setNetwork] = useState('testnet'); const [betUsdt, setBetUsdt] = useState(50); const [leverage, setLeverage] = useState(5); @@ -174,15 +174,28 @@ export function LlmTradeForm() {
- + setCandleCount(Number(e.target.value))} + onChange={(e) => { + const v = Number(e.target.value); + if (!Number.isFinite(v)) return; + setCandleCount(Math.min(120, Math.max(30, v))); + }} className="w-full h-9 px-3 rounded-md border border-input bg-transparent text-sm focus:outline-none focus:ring-2 focus:ring-ring" /> +

+ 인디케이터 계산은 220+ 캔들로 자동 수행됩니다. 이 값은 LLM이 보는 row 개수만 + 결정합니다. +

diff --git a/packages/exchange-adapters/src/binance/binance.rest.ts b/packages/exchange-adapters/src/binance/binance.rest.ts index 831acaa..0fd1bd8 100644 --- a/packages/exchange-adapters/src/binance/binance.rest.ts +++ b/packages/exchange-adapters/src/binance/binance.rest.ts @@ -12,6 +12,9 @@ import { MarginType, SymbolFilter, IncomeRecord, + FundingRateRecord, + OpenInterestSnapshot, + OpenInterestPoint, } from '@coin/types'; import { IExchangeRest } from '../interfaces/exchange-rest'; @@ -462,6 +465,77 @@ export class BinanceRest implements IExchangeRest { })); } + // ── Public futures market data (no signature) ──────────────────── + + async getFundingRateHistory(symbol: string, limit = 8): Promise { + const url = `${publicBaseUrl()}/fapi/v1/fundingRate?symbol=${symbol}&limit=${limit}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Binance fapi fundingRate ${res.status}: ${await res.text()}`); + const data = (await res.json()) as Array<{ + symbol: string; + fundingTime: number; + fundingRate: string; + }>; + return data.map((r) => ({ + symbol: r.symbol, + fundingTime: r.fundingTime, + fundingRate: r.fundingRate, + })); + } + + async getCurrentFundingRate( + symbol: string, + ): Promise<{ symbol: string; lastFundingRate: string; nextFundingTime: number }> { + const url = `${publicBaseUrl()}/fapi/v1/premiumIndex?symbol=${symbol}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Binance fapi premiumIndex ${res.status}: ${await res.text()}`); + const data = (await res.json()) as { + symbol: string; + lastFundingRate: string; + nextFundingTime: number; + }; + return { + symbol: data.symbol, + lastFundingRate: data.lastFundingRate, + nextFundingTime: data.nextFundingTime, + }; + } + + async getOpenInterest(symbol: string): Promise { + const url = `${publicBaseUrl()}/fapi/v1/openInterest?symbol=${symbol}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Binance fapi openInterest ${res.status}: ${await res.text()}`); + const data = (await res.json()) as { + symbol: string; + openInterest: string; + time: number; + }; + return { symbol: data.symbol, openInterest: data.openInterest, timestamp: data.time }; + } + + async getOpenInterestHistory( + symbol: string, + period: '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '12h' | '1d', + limit = 24, + ): Promise { + const url = `${publicBaseUrl()}/futures/data/openInterestHist?symbol=${symbol}&period=${period}&limit=${limit}`; + const res = await fetch(url); + if (!res.ok) + throw new Error(`Binance fapi openInterestHist ${res.status}: ${await res.text()}`); + const data = (await res.json()) as Array<{ + symbol: string; + sumOpenInterest: string; + sumOpenInterestValue: string; + timestamp: number; + }>; + return data.map((r) => ({ + symbol: r.symbol, + sumOpenInterest: r.sumOpenInterest, + sumOpenInterestValueUsdt: r.sumOpenInterestValue, + timestamp: r.timestamp, + })); + } + async getSymbolFilter(symbol: string): Promise { const info = await this.fetchExchangeInfo(); const sym = info.symbols.find((s) => s.symbol === symbol); diff --git a/packages/exchange-adapters/src/interfaces/exchange-rest.ts b/packages/exchange-adapters/src/interfaces/exchange-rest.ts index 4bc2c39..e5714e8 100644 --- a/packages/exchange-adapters/src/interfaces/exchange-rest.ts +++ b/packages/exchange-adapters/src/interfaces/exchange-rest.ts @@ -11,6 +11,9 @@ import { MarginType, SymbolFilter, IncomeRecord, + FundingRateRecord, + OpenInterestSnapshot, + OpenInterestPoint, } from '@coin/types'; export interface IExchangeRest { @@ -77,4 +80,16 @@ export interface IExchangeRest { limit?: number; }, ): Promise; + + // Public futures market data (no signature required) + getFundingRateHistory(symbol: string, limit?: number): Promise; + getCurrentFundingRate( + symbol: string, + ): Promise<{ symbol: string; lastFundingRate: string; nextFundingTime: number }>; + getOpenInterest(symbol: string): Promise; + getOpenInterestHistory( + symbol: string, + period: '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '12h' | '1d', + limit?: number, + ): Promise; } diff --git a/packages/indicators/package.json b/packages/indicators/package.json new file mode 100644 index 0000000..96ce4de --- /dev/null +++ b/packages/indicators/package.json @@ -0,0 +1,22 @@ +{ + "name": "@coin/indicators", + "version": "0.0.0", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test:unit": "vitest run" + }, + "dependencies": { + "@coin/types": "workspace:*", + "technicalindicators": "^3.1.0" + }, + "devDependencies": { + "@coin/tsconfig": "workspace:*", + "@vitest/coverage-v8": "^4.1.2", + "typescript": "^5", + "vitest": "^4" + } +} diff --git a/packages/indicators/src/compute.test.ts b/packages/indicators/src/compute.test.ts new file mode 100644 index 0000000..bf178df --- /dev/null +++ b/packages/indicators/src/compute.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import type { Candle } from '@coin/types'; +import { computeIndicators } from './compute'; + +function mkCandles(closes: number[]): Candle[] { + return closes.map((c, i) => ({ + exchange: 'binance' as const, + symbol: 'BTCUSDT', + interval: '1h', + open: String(c), + high: String(c + 1), + low: String(c - 1), + close: String(c), + volume: '1', + timestamp: 1700000000000 + i * 60_000, + })); +} + +describe('computeIndicators', () => { + it('output length equals input length, with warmup nulls at the start', () => { + const candles = mkCandles(Array.from({ length: 250 }, (_, i) => 60_000 + i * 10)); + const series = computeIndicators(candles); + expect(series.rows.length).toBe(250); + + // EMA200 needs ~200 candles of warmup + expect(series.rows[10].ema200).toBeNull(); + expect(series.rows[249].ema200).not.toBeNull(); + + // RSI14 needs 14 warmup + expect(series.rows[5].rsi14).toBeNull(); + expect(series.rows[249].rsi14).not.toBeNull(); + }); + + it('an upward-trending close series produces RSI > 70 near the end (overbought)', () => { + const closes = Array.from({ length: 50 }, (_, i) => 100 + i * 2); + const series = computeIndicators(mkCandles(closes)); + const rsi = series.rows[series.rows.length - 1].rsi14!; + expect(rsi).toBeGreaterThan(90); + }); + + it('flat closes produce a defined RSI value (no warmup nulls past period 14)', () => { + const closes = Array.from({ length: 50 }, () => 100); + const series = computeIndicators(mkCandles(closes)); + // technicalindicators returns RSI=100 when there are no down-moves; + // we just assert a finite value past warmup, not the specific number. + const rsi = series.rows[series.rows.length - 1].rsi14; + expect(rsi).not.toBeNull(); + expect(Number.isFinite(rsi!)).toBe(true); + }); + + it('Bollinger middle equals SMA(close, 20) at the last row', () => { + const closes = Array.from({ length: 30 }, (_, i) => 100 + i); + const series = computeIndicators(mkCandles(closes)); + const last = series.rows[series.rows.length - 1]; + const sma20 = closes.slice(-20).reduce((s, v) => s + v, 0) / 20; + expect(last.bbMiddle).not.toBeNull(); + expect(Math.abs(last.bbMiddle! - sma20)).toBeLessThan(0.01); + }); + + it('ATR is positive for non-flat candles', () => { + const candles = mkCandles(Array.from({ length: 30 }, (_, i) => 100 + i)); + const series = computeIndicators(candles); + const atr = series.rows[series.rows.length - 1].atr14; + expect(atr).not.toBeNull(); + expect(atr!).toBeGreaterThan(0); + }); +}); diff --git a/packages/indicators/src/compute.ts b/packages/indicators/src/compute.ts new file mode 100644 index 0000000..38d1788 --- /dev/null +++ b/packages/indicators/src/compute.ts @@ -0,0 +1,103 @@ +import { EMA, RSI, BollingerBands, MACD, ATR } from 'technicalindicators'; +import type { Candle, IndicatorRow, IndicatorSeries } from '@coin/types'; + +export interface ComputeOptions { + emaPeriods?: [number, number, number]; + rsiPeriod?: number; + bbPeriod?: number; + bbStdDev?: number; + macdFast?: number; + macdSlow?: number; + macdSignal?: number; + atrPeriod?: number; +} + +const DEFAULTS: Required = { + emaPeriods: [20, 50, 200], + rsiPeriod: 14, + bbPeriod: 20, + bbStdDev: 2, + macdFast: 12, + macdSlow: 26, + macdSignal: 9, + atrPeriod: 14, +}; + +/** + * Compute the standard indicator suite over an OHLCV candle window. The + * returned series is index-aligned with the input — one IndicatorRow per + * input candle. Indicator values inside the warmup period (when there isn't + * enough prior data to produce a stable value) are returned as `null` so the + * caller can pass that semantics through to consumers (LLM prompts, UI). + * + * Wraps the `technicalindicators` library so the rest of the codebase never + * imports it directly. If we ever swap the engine, only this file changes. + */ +export function computeIndicators(candles: Candle[], opts: ComputeOptions = {}): IndicatorSeries { + const o = { ...DEFAULTS, ...opts }; + const n = candles.length; + const closes = candles.map((c) => Number(c.close)); + const highs = candles.map((c) => Number(c.high)); + const lows = candles.map((c) => Number(c.low)); + + const [pE1, pE2, pE3] = o.emaPeriods; + const ema1 = EMA.calculate({ period: pE1, values: closes }); + const ema2 = EMA.calculate({ period: pE2, values: closes }); + const ema3 = EMA.calculate({ period: pE3, values: closes }); + const rsi = RSI.calculate({ period: o.rsiPeriod, values: closes }); + const bb = BollingerBands.calculate({ + period: o.bbPeriod, + stdDev: o.bbStdDev, + values: closes, + }); + const macd = MACD.calculate({ + fastPeriod: o.macdFast, + slowPeriod: o.macdSlow, + signalPeriod: o.macdSignal, + values: closes, + SimpleMAOscillator: false, + SimpleMASignal: false, + }); + const atr = ATR.calculate({ period: o.atrPeriod, high: highs, low: lows, close: closes }); + + // Each output array is shorter than the input by (period - 1). To align, + // we right-justify the output: the LAST element corresponds to candles[n-1], + // and the first (n - output.length) candles get null. + const align = (values: T[]): (T | null)[] => { + const padded: (T | null)[] = new Array(n).fill(null); + const offset = n - values.length; + for (let i = 0; i < values.length; i++) { + padded[offset + i] = values[i]; + } + return padded; + }; + + const ema1A = align(ema1); + const ema2A = align(ema2); + const ema3A = align(ema3); + const rsiA = align(rsi); + const bbA = align(bb); + const macdA = align(macd); + const atrA = align(atr); + + const rows: IndicatorRow[] = []; + for (let i = 0; i < n; i++) { + const b = bbA[i]; + const m = macdA[i]; + rows.push({ + ema20: ema1A[i] ?? null, + ema50: ema2A[i] ?? null, + ema200: ema3A[i] ?? null, + rsi14: rsiA[i] ?? null, + bbUpper: b?.upper ?? null, + bbMiddle: b?.middle ?? null, + bbLower: b?.lower ?? null, + macd: m?.MACD ?? null, + macdSignal: m?.signal ?? null, + macdHistogram: m?.histogram ?? null, + atr14: atrA[i] ?? null, + }); + } + + return { rows }; +} diff --git a/packages/indicators/src/index.ts b/packages/indicators/src/index.ts new file mode 100644 index 0000000..88790fa --- /dev/null +++ b/packages/indicators/src/index.ts @@ -0,0 +1 @@ +export { computeIndicators, type ComputeOptions } from './compute'; diff --git a/packages/indicators/tsconfig.json b/packages/indicators/tsconfig.json new file mode 100644 index 0000000..722790d --- /dev/null +++ b/packages/indicators/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/indicators/vitest.config.ts b/packages/indicators/vitest.config.ts new file mode 100644 index 0000000..6ec74ee --- /dev/null +++ b/packages/indicators/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/types/src/exchange.ts b/packages/types/src/exchange.ts index 04e1812..1c0c001 100644 --- a/packages/types/src/exchange.ts +++ b/packages/types/src/exchange.ts @@ -159,3 +159,51 @@ export interface IncomeRecord { tranId?: string; info?: string; } + +// ── Futures sentiment ──────────────────────────────────────────────── + +export interface FundingRateRecord { + symbol: string; + fundingTime: number; + fundingRate: string; +} + +export interface OpenInterestSnapshot { + symbol: string; + /** OI in coin units (Binance USDT-M `openInterest` field). */ + openInterest: string; + timestamp: number; +} + +export interface OpenInterestPoint { + symbol: string; + /** Coin-denominated OI (`sumOpenInterest` from openInterestHist). */ + sumOpenInterest: string; + /** USDT-denominated OI value (`sumOpenInterestValue` from openInterestHist). */ + sumOpenInterestValueUsdt: string; + timestamp: number; +} + +// ── Indicators (computed by @coin/indicators) ──────────────────────── + +/** One row aligned with one OHLCV candle. Indicator values are `null` + * during the warmup period (when there isn't enough prior data to compute + * the value reliably). */ +export interface IndicatorRow { + ema20: number | null; + ema50: number | null; + ema200: number | null; + rsi14: number | null; + bbUpper: number | null; + bbMiddle: number | null; + bbLower: number | null; + macd: number | null; + macdSignal: number | null; + macdHistogram: number | null; + atr14: number | null; +} + +export interface IndicatorSeries { + /** Same length as the input candles array. `rows[i]` corresponds to candles[i]. */ + rows: IndicatorRow[]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91db15d..086e546 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: '@coin/exchange-adapters': specifier: workspace:* version: link:../../packages/exchange-adapters + '@coin/indicators': + specifier: workspace:* + version: link:../../packages/indicators '@coin/kafka-contracts': specifier: workspace:* version: link:../../packages/kafka-contracts @@ -478,6 +481,28 @@ importers: specifier: ^4.1.2 version: 4.1.2(@types/node@22.19.15)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0)(msw@2.12.14(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3)) + packages/indicators: + dependencies: + '@coin/types': + specifier: workspace:* + version: link:../types + technicalindicators: + specifier: ^3.1.0 + version: 3.1.0 + devDependencies: + '@coin/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@vitest/coverage-v8': + specifier: ^4.1.2 + version: 4.1.2(@vitest/browser@4.1.2(msw@2.12.14(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.2))(vitest@4.1.2) + typescript: + specifier: ^5 + version: 5.9.3 + vitest: + specifier: ^4 + version: 4.1.2(@types/node@22.19.15)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0)(msw@2.12.14(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3)) + packages/kafka-contracts: dependencies: '@coin/types':