first day of vibe coding
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
# 依赖包
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Next.js 构建输出
|
||||
/.next/
|
||||
/out
|
||||
/build
|
||||
|
||||
# 环境变量 (最重要:防止泄露)
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# 调试日志
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# 系统与编辑器文件
|
||||
.DS_Store
|
||||
*.pem
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 类型定义
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { TrumpPost, BotTrade, BotPerformance, Candle } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { usePriceSocket } from '@/lib/useRealtimeData'
|
||||
import { getPrices } from '@/lib/api'
|
||||
import KpiRow from '@/components/dashboard/KpiRow'
|
||||
import ChartPanel from '@/components/dashboard/ChartPanel'
|
||||
import BotPanel from '@/components/dashboard/BotPanel'
|
||||
import TradesTable from '@/components/trades/TradesTable'
|
||||
|
||||
interface Props {
|
||||
initialPosts: TrumpPost[]
|
||||
initialPerformance?: BotPerformance
|
||||
initialTrades: BotTrade[]
|
||||
}
|
||||
|
||||
export default function DashboardClient({ initialPosts, initialPerformance, initialTrades }: Props) {
|
||||
const { asset, timeframe, setLivePrice } = useDashboardStore()
|
||||
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
|
||||
const [candles, setCandles] = useState<Candle[]>([])
|
||||
const [candlesLoading, setCandlesLoading] = useState(false)
|
||||
|
||||
usePriceSocket({
|
||||
onPrice: (a, price) => setLivePrice(a, price),
|
||||
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 50)),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setCandles([])
|
||||
setCandlesLoading(true)
|
||||
getPrices(asset, timeframe)
|
||||
.then(setCandles)
|
||||
.catch(() => {})
|
||||
.finally(() => setCandlesLoading(false))
|
||||
}, [asset, timeframe])
|
||||
|
||||
return (
|
||||
<div className="pt-14">
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-6 flex flex-col gap-4">
|
||||
{/* KPI 行 */}
|
||||
<KpiRow performance={initialPerformance} postsCount={posts.length} />
|
||||
|
||||
{/* 主内容区:图表 + Bot 面板 */}
|
||||
<div className="flex gap-4 items-start w-full">
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
<ChartPanel posts={posts} candles={candles} />
|
||||
{candlesLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 rounded-[10px] pointer-events-none">
|
||||
<span className="text-[#555555] text-sm">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-[280px] shrink-0">
|
||||
<BotPanel performance={initialPerformance} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 交易记录 */}
|
||||
<TradesTable trades={initialTrades} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { WagmiProvider } from 'wagmi'
|
||||
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { config } from '@/lib/wagmi'
|
||||
import '@rainbow-me/rainbowkit/styles.css'
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Providers({ children }: ProvidersProps) {
|
||||
const [queryClient] = useState(() => new QueryClient())
|
||||
|
||||
return (
|
||||
<WagmiProvider config={config}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RainbowKitProvider
|
||||
theme={darkTheme({
|
||||
accentColor: '#f97316',
|
||||
accentColorForeground: '#000000',
|
||||
borderRadius: 'medium',
|
||||
overlayBlur: 'small',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</RainbowKitProvider>
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
|
||||
export default async function AnalyticsPage() {
|
||||
const t = await getTranslations('analytics')
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-6">
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center">
|
||||
<h1 className="text-[22px] font-medium text-white mb-3">{t('title')}</h1>
|
||||
<p className="text-[14px] text-[#555555]">{t('comingSoon')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
/* Remove default focus ring and apply custom */
|
||||
button:focus-visible {
|
||||
outline: 1px solid #f97316;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 1px solid #222222;
|
||||
outline-offset: 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { getMessages } from 'next-intl/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { locales } from '@/i18n'
|
||||
import Navbar from '@/components/nav/Navbar'
|
||||
import Providers from './Providers'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'TrumpSignal — AI-powered crypto trading signals',
|
||||
description: 'Trade crypto based on Trump social media signals with AI confidence scoring.',
|
||||
}
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
params: { locale: string }
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params: { locale } }: LayoutProps) {
|
||||
if (!locales.includes(locale as (typeof locales)[number])) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const messages = await getMessages()
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body className={`${inter.className} bg-[#000000] min-h-screen text-white`}>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Providers>
|
||||
<Navbar locale={locale} />
|
||||
<main className="pt-14">{children}</main>
|
||||
</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getPosts, getPerformance, getTrades } from '@/lib/api'
|
||||
import DashboardClient from './DashboardClient'
|
||||
|
||||
export const revalidate = 30
|
||||
|
||||
export default async function OverviewPage() {
|
||||
const [posts, performance, trades] = await Promise.allSettled([
|
||||
getPosts(500, 1),
|
||||
getPerformance(),
|
||||
getTrades(20, 1),
|
||||
])
|
||||
|
||||
return (
|
||||
<DashboardClient
|
||||
initialPosts={posts.status === 'fulfilled' ? posts.value : []}
|
||||
initialPerformance={performance.status === 'fulfilled' ? performance.value : undefined}
|
||||
initialTrades={trades.status === 'fulfilled' ? trades.value : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
|
||||
export default async function PostsPage() {
|
||||
const t = await getTranslations('posts')
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-6">
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center">
|
||||
<h1 className="text-[22px] font-medium text-white mb-3">{t('title')}</h1>
|
||||
<p className="text-[14px] text-[#555555]">{t('comingSoon')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useConnectModal } from '@rainbow-me/rainbowkit'
|
||||
|
||||
export default function SettingsClient() {
|
||||
const t = useTranslations('settings')
|
||||
const { isConnected } = useAccount()
|
||||
const { openConnectModal } = useConnectModal()
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center">
|
||||
<p className="text-[14px] text-[#555555] mb-4">{t('connectRequired')}</p>
|
||||
<button
|
||||
onClick={openConnectModal}
|
||||
className="bg-[#f97316] text-black font-medium text-[13px] rounded-lg px-6 py-2.5 hover:bg-[#fb923c] transition-colors"
|
||||
>
|
||||
Connect wallet
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center">
|
||||
<p className="text-[14px] text-[#555555]">{t('comingSoon')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import SettingsClient from './SettingsClient'
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const t = await getTranslations('settings')
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-6">
|
||||
<h1 className="text-[22px] font-medium text-white mb-5">{t('title')}</h1>
|
||||
<SettingsClient />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import TradesTable from '@/components/trades/TradesTable'
|
||||
import { getTrades } from '@/lib/api'
|
||||
|
||||
export const revalidate = 10
|
||||
|
||||
export default async function TradesPage() {
|
||||
const t = await getTranslations('trades')
|
||||
const trades = await getTrades(100, 1).catch(() => [])
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-6 pt-20">
|
||||
<h1 className="text-[22px] font-medium text-white mb-5">{t('title')}</h1>
|
||||
<TradesTable trades={trades} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
|
||||
async function handler(request: NextRequest): Promise<NextResponse> {
|
||||
const url = new URL(request.url)
|
||||
const targetPath = url.pathname.replace('/api/proxy', '')
|
||||
const targetUrl = `${API_BASE}${targetPath}${url.search}`
|
||||
|
||||
const headers = new Headers(request.headers)
|
||||
// Remove Next.js internal headers
|
||||
headers.delete('host')
|
||||
headers.delete('x-forwarded-for')
|
||||
headers.delete('x-forwarded-host')
|
||||
headers.delete('x-forwarded-proto')
|
||||
|
||||
const body =
|
||||
request.method !== 'GET' && request.method !== 'HEAD'
|
||||
? await request.arrayBuffer()
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
const responseHeaders = new Headers(upstream.headers)
|
||||
responseHeaders.delete('transfer-encoding')
|
||||
|
||||
const responseBody = await upstream.arrayBuffer()
|
||||
|
||||
return new NextResponse(responseBody, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Upstream request failed', detail: String(err) },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = handler
|
||||
export const POST = handler
|
||||
export const PUT = handler
|
||||
export const PATCH = handler
|
||||
export const DELETE = handler
|
||||
export const HEAD = handler
|
||||
export const OPTIONS = handler
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useConnectModal } from '@rainbow-me/rainbowkit'
|
||||
import type { BotPerformance } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { formatPct, formatHold } from '@/lib/utils'
|
||||
|
||||
const MOCK_PERFORMANCE: BotPerformance = {
|
||||
period_days: 30,
|
||||
total_trades: 89,
|
||||
win_rate: 0.73,
|
||||
net_pnl_usd: 12840,
|
||||
avg_hold_seconds: 14 * 60,
|
||||
max_drawdown_pct: 8.2,
|
||||
}
|
||||
|
||||
interface BotPanelProps {
|
||||
performance?: BotPerformance
|
||||
}
|
||||
|
||||
export default function BotPanel({ performance = MOCK_PERFORMANCE }: BotPanelProps) {
|
||||
const t = useTranslations('bot')
|
||||
const { isSubscribed, setSubscribed } = useDashboardStore()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { openConnectModal } = useConnectModal()
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
const stats = [
|
||||
{ label: t('winRate'), value: formatPct(performance.win_rate * 100 - 100 + performance.win_rate * 100), display: `${Math.round(performance.win_rate * 100)}%` },
|
||||
{ label: t('netPnl'), value: `+$${performance.net_pnl_usd.toLocaleString()}`, positive: true },
|
||||
{ label: t('totalTrades'), value: String(performance.total_trades) },
|
||||
{ label: t('avgHold'), value: formatHold(performance.avg_hold_seconds) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="w-[300px] shrink-0 flex flex-col gap-3">
|
||||
{/* Performance card */}
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-4">
|
||||
{t('title')} · {t('period')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label}>
|
||||
<p className="text-[10px] text-[#555555] mb-0.5">{stat.label}</p>
|
||||
<p className={`text-[16px] font-medium ${stat.positive ? 'text-[#4ade80]' : 'text-white'}`}>
|
||||
{stat.display ?? stat.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-[#141414]" />
|
||||
|
||||
{/* Conditional bottom section */}
|
||||
{!isConnected && (
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3">
|
||||
<p className="text-[14px] font-medium text-white">{t('connectCta')}</p>
|
||||
<p className="text-[12px] text-[#555555] leading-relaxed">{t('connectDesc')}</p>
|
||||
<button
|
||||
onClick={openConnectModal}
|
||||
className="w-full bg-[#f97316] text-black font-medium text-[13px] rounded-lg py-2.5 hover:bg-[#fb923c] transition-colors"
|
||||
>
|
||||
{t('connectWalletFree')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && !isSubscribed && (
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[13px] font-medium text-[#333333]">{t('settingsTitle')}</p>
|
||||
<span className="text-[10px] text-[#333333] border border-[#1e1e1e] rounded-full px-2 py-0.5">
|
||||
Locked
|
||||
</span>
|
||||
</div>
|
||||
{/* Disabled API key input */}
|
||||
<div className="opacity-40">
|
||||
<label className="text-[11px] text-[#555555] block mb-1">{t('apiKeyLabel')}</label>
|
||||
<input
|
||||
disabled
|
||||
placeholder={t('apiKeyPlaceholder')}
|
||||
className="w-full bg-[#060606] border border-[#141414] rounded-lg px-3 py-2 text-[12px] text-[#333333] placeholder-[#222222] cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSubscribed(true)}
|
||||
className="w-full bg-[#f97316] text-black font-medium text-[13px] rounded-lg py-2.5 hover:bg-[#fb923c] transition-colors"
|
||||
>
|
||||
{t('subscribeBtn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && isSubscribed && (
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[13px] font-medium text-white">{t('settingsTitle')}</p>
|
||||
<span className="text-[10px] text-[#4ade80] border border-[#1a4a2a] bg-[#0d2e1a] rounded-full px-2 py-0.5">
|
||||
{t('activeStatus')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-[#555555] block mb-1">{t('apiKeyLabel')}</label>
|
||||
<input
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t('apiKeyPlaceholder')}
|
||||
className="w-full bg-[#060606] border border-[#141414] rounded-lg px-3 py-2 text-[12px] text-white placeholder-[#333333] focus:outline-none focus:border-[#222222]"
|
||||
/>
|
||||
</div>
|
||||
<button className="w-full bg-[#141414] text-white border border-[#1a1a1a] text-[13px] rounded-lg py-2 hover:bg-[#0d0d0d] transition-colors">
|
||||
{t('saveKey')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { TrumpPost, Candle } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import Pill from '@/components/ui/Pill'
|
||||
import PostCards, { MOCK_POSTS } from './PostCards'
|
||||
import ExpandDetail from './ExpandDetail'
|
||||
|
||||
interface ChartPanelProps {
|
||||
posts?: TrumpPost[]
|
||||
candles?: Candle[]
|
||||
}
|
||||
|
||||
export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPanelProps) {
|
||||
const { asset, timeframe, setAsset, setTimeframe, selectedPostId, setSelectedPost } = useDashboardStore()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<unknown>(null)
|
||||
const seriesRef = useRef<unknown>(null)
|
||||
const fittedRef = useRef(false)
|
||||
// Keep latest posts/selectedPostId accessible inside chart callbacks without re-subscribing
|
||||
const postsRef = useRef(posts)
|
||||
postsRef.current = posts
|
||||
const selectedPostIdRef = useRef(selectedPostId)
|
||||
selectedPostIdRef.current = selectedPostId
|
||||
|
||||
const assets: Array<'BTC' | 'ETH'> = ['BTC', 'ETH']
|
||||
const timeframes: Array<'5m' | '15m' | '1H' | '4H' | '1D' | '1W'> = ['5m', '15m', '1H', '4H', '1D', '1W']
|
||||
|
||||
const selectedPost = posts.find((p) => p.id === selectedPostId) ?? null
|
||||
|
||||
// Create chart once on mount
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || typeof window === 'undefined') return
|
||||
|
||||
let destroyed = false
|
||||
|
||||
import('lightweight-charts').then(({ createChart, CrosshairMode }) => {
|
||||
if (destroyed || !containerRef.current) return
|
||||
|
||||
const chart = createChart(containerRef.current, {
|
||||
width: containerRef.current.clientWidth,
|
||||
height: 360,
|
||||
layout: {
|
||||
background: { color: '#050505' },
|
||||
textColor: '#555555',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#111111' },
|
||||
horzLines: { color: '#111111' },
|
||||
},
|
||||
crosshair: { mode: CrosshairMode.Normal },
|
||||
rightPriceScale: { borderColor: '#1a1a1a' },
|
||||
timeScale: {
|
||||
borderColor: '#1a1a1a',
|
||||
timeVisible: true,
|
||||
rightOffset: 5,
|
||||
barSpacing: 10,
|
||||
},
|
||||
handleScroll: true,
|
||||
handleScale: true,
|
||||
})
|
||||
|
||||
// @ts-expect-error lightweight-charts type
|
||||
chartRef.current = chart
|
||||
|
||||
const series = chart.addCandlestickSeries({
|
||||
upColor: '#4ade80',
|
||||
downColor: '#ef4444',
|
||||
borderUpColor: '#4ade80',
|
||||
borderDownColor: '#ef4444',
|
||||
wickUpColor: '#4ade80',
|
||||
wickDownColor: '#ef4444',
|
||||
})
|
||||
|
||||
// @ts-expect-error lightweight-charts type
|
||||
seriesRef.current = series
|
||||
|
||||
// Click on chart: find nearest post marker within 2-bar tolerance
|
||||
chart.subscribeClick((param: { time?: number | string }) => {
|
||||
if (!param.time) return
|
||||
const clickTime = typeof param.time === 'number' ? param.time : 0
|
||||
if (!clickTime) return
|
||||
|
||||
const allPosts = postsRef.current
|
||||
let closest: TrumpPost | null = null
|
||||
let closestDiff = Infinity
|
||||
|
||||
for (const p of allPosts) {
|
||||
if (!p.published_at) continue
|
||||
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
|
||||
const diff = Math.abs(pt - clickTime)
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff
|
||||
closest = p
|
||||
}
|
||||
}
|
||||
|
||||
// Only select if click is within 2 hours of a post (7200 seconds)
|
||||
if (closest && closestDiff <= 7200) {
|
||||
const newId = closest.id === selectedPostIdRef.current ? null : closest.id
|
||||
setSelectedPost(newId)
|
||||
} else {
|
||||
setSelectedPost(null)
|
||||
}
|
||||
})
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (containerRef.current && !destroyed) {
|
||||
chart.applyOptions({ width: containerRef.current.clientWidth })
|
||||
}
|
||||
})
|
||||
ro.observe(containerRef.current)
|
||||
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
destroyed = true
|
||||
fittedRef.current = false
|
||||
if (chartRef.current) {
|
||||
// @ts-expect-error lightweight-charts type
|
||||
chartRef.current.remove()
|
||||
chartRef.current = null
|
||||
seriesRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Update candles + markers whenever data changes
|
||||
useEffect(() => {
|
||||
const series = seriesRef.current
|
||||
const chart = chartRef.current
|
||||
if (!series || !chart || candles.length === 0) return
|
||||
|
||||
const sorted = [...candles].sort((a, b) => a.time - b.time)
|
||||
|
||||
// @ts-expect-error lightweight-charts type
|
||||
series.setData(sorted.map((c) => ({
|
||||
time: c.time as number,
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
})))
|
||||
|
||||
// Show markers for all posts within visible time range
|
||||
const minTime = sorted[0].time
|
||||
const maxTime = sorted[sorted.length - 1].time
|
||||
|
||||
const visible = posts.filter((p) => {
|
||||
if (!p.published_at) return false
|
||||
const t = Math.floor(new Date(p.published_at).getTime() / 1000)
|
||||
return t >= minTime && t <= maxTime
|
||||
})
|
||||
|
||||
if (visible.length > 0) {
|
||||
const markers = [...visible]
|
||||
.sort((a, b) => new Date(a.published_at).getTime() - new Date(b.published_at).getTime())
|
||||
.map((p) => ({
|
||||
time: Math.floor(new Date(p.published_at).getTime() / 1000) as number,
|
||||
position: 'aboveBar' as const,
|
||||
color: p.id === selectedPostId
|
||||
? '#fb923c'
|
||||
: p.sentiment === 'bearish'
|
||||
? '#ef4444'
|
||||
: '#f97316',
|
||||
shape: 'circle' as const,
|
||||
text: '',
|
||||
size: p.id === selectedPostId ? 2 : 1,
|
||||
}))
|
||||
// @ts-expect-error lightweight-charts type
|
||||
series.setMarkers(markers)
|
||||
}
|
||||
|
||||
if (!fittedRef.current) {
|
||||
// @ts-expect-error lightweight-charts type
|
||||
chart.timeScale().fitContent()
|
||||
fittedRef.current = true
|
||||
}
|
||||
}, [candles, posts, selectedPostId])
|
||||
|
||||
// Reset fit flag on timeframe/asset switch
|
||||
useEffect(() => {
|
||||
fittedRef.current = false
|
||||
}, [asset, timeframe])
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-4 flex flex-col gap-3">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{assets.map((a) => (
|
||||
<Pill key={a} active={asset === a} onClick={() => setAsset(a)}>
|
||||
{a}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{timeframes.map((tf) => (
|
||||
<Pill key={tf} active={timeframe === tf} onClick={() => setTimeframe(tf as '4H' | '1D' | '1W')}>
|
||||
{tf}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full rounded-lg overflow-hidden cursor-crosshair"
|
||||
style={{ height: 360, background: '#050505' }}
|
||||
/>
|
||||
|
||||
{/* Selected post detail — shown immediately below chart */}
|
||||
<ExpandDetail post={selectedPost} />
|
||||
|
||||
{/* Post cards list */}
|
||||
<PostCards posts={posts} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import type { TrumpPost } from '@/types'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { formatPct } from '@/lib/utils'
|
||||
|
||||
interface ExpandDetailProps {
|
||||
post: TrumpPost | null
|
||||
}
|
||||
|
||||
export default function ExpandDetail({ post }: ExpandDetailProps) {
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
post ? 'max-h-[300px] opacity-100 mt-3' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{post && (
|
||||
<div className="bg-[#0a0a0a] border border-[#f97316] rounded-[10px] p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Post content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant={post.source} />
|
||||
<Badge variant={post.sentiment} />
|
||||
<span className="text-[11px] text-[#555555]">
|
||||
{new Date(post.published_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-[#e2e8f0] leading-relaxed">{post.text}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="shrink-0 w-[260px]">
|
||||
{/* AI Confidence */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[11px] uppercase tracking-wider text-[#555555]">
|
||||
AI Confidence
|
||||
</span>
|
||||
<span className="text-[13px] font-medium text-[#818cf8]">
|
||||
{post.ai_confidence}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#141414] rounded-full">
|
||||
<div
|
||||
className="h-full bg-[#818cf8] rounded-full transition-all duration-500"
|
||||
style={{ width: `${post.ai_confidence}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price impact */}
|
||||
{post.price_impact ? (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-2">
|
||||
Price Impact ({post.price_impact.asset})
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ label: '5m', value: post.price_impact.m5 },
|
||||
{ label: '15m', value: post.price_impact.m15 },
|
||||
{ label: '1h', value: post.price_impact.m1h },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-[#050505] border border-[#141414] rounded-lg p-2 text-center">
|
||||
<p className="text-[10px] text-[#555555] mb-1">{item.label}</p>
|
||||
<p
|
||||
className={`text-[13px] font-medium ${
|
||||
item.value >= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{formatPct(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-[#333333]">No significant price impact detected.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { BotPerformance } from '@/types'
|
||||
import { formatPrice, formatPct } from '@/lib/utils'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
|
||||
interface KpiRowProps {
|
||||
performance?: BotPerformance
|
||||
postsCount?: number
|
||||
}
|
||||
|
||||
interface StatCard {
|
||||
labelKey: string
|
||||
value: string
|
||||
change?: string
|
||||
changePositive?: boolean
|
||||
}
|
||||
|
||||
export default function KpiRow({ performance, postsCount }: KpiRowProps) {
|
||||
const t = useTranslations('kpi')
|
||||
const { livePrices } = useDashboardStore()
|
||||
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
labelKey: 'btcPrice',
|
||||
value: formatPrice(livePrices.BTC ?? 94230),
|
||||
change: livePrices.BTC ? 'live' : '+1.4%',
|
||||
changePositive: true,
|
||||
},
|
||||
{
|
||||
labelKey: 'ethPrice',
|
||||
value: formatPrice(livePrices.ETH ?? 1847),
|
||||
change: livePrices.ETH ? 'live' : '-0.8%',
|
||||
changePositive: false,
|
||||
},
|
||||
{
|
||||
labelKey: 'postsTracked',
|
||||
value: postsCount != null ? postsCount.toLocaleString() : '1,247',
|
||||
change: '+12 today',
|
||||
changePositive: true,
|
||||
},
|
||||
{
|
||||
labelKey: 'avgMove',
|
||||
value: performance
|
||||
? formatPct(performance.net_pnl_usd > 0 ? 2.3 : -2.3)
|
||||
: formatPct(2.3),
|
||||
change: 'after Trump posts',
|
||||
changePositive: true,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.labelKey}
|
||||
className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5"
|
||||
>
|
||||
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-2">
|
||||
{t(stat.labelKey as 'btcPrice' | 'ethPrice' | 'postsTracked' | 'avgMove')}
|
||||
</p>
|
||||
<p className="text-[22px] font-medium text-white leading-none mb-1.5">{stat.value}</p>
|
||||
{stat.change && (
|
||||
<p
|
||||
className={`text-[12px] ${
|
||||
stat.changePositive ? 'text-[#4ade80]' : 'text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{stat.change}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { TrumpPost } from '@/types'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { formatPct } from '@/lib/utils'
|
||||
|
||||
const MOCK_POSTS: TrumpPost[] = [
|
||||
{
|
||||
id: 1,
|
||||
text: 'BITCOIN IS THE FUTURE OF MONEY! We will make America the crypto capital of the world. BIG things coming very soon. The dollar will be STRONGER than ever with Bitcoin as our reserve!',
|
||||
source: 'truth',
|
||||
published_at: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
|
||||
sentiment: 'bullish',
|
||||
ai_confidence: 91,
|
||||
relevant: true,
|
||||
price_impact: {
|
||||
asset: 'BTC',
|
||||
m5: 1.2,
|
||||
m15: 2.8,
|
||||
m1h: 3.4,
|
||||
price_at_post: 92800,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text: 'Ethereum and all these so-called "smart contracts" are nothing but a scam by the radical left globalists. Very bad for America. We need REAL money, not fake computer tricks!',
|
||||
source: 'x',
|
||||
published_at: new Date(Date.now() - 1000 * 60 * 47).toISOString(),
|
||||
sentiment: 'bearish',
|
||||
ai_confidence: 84,
|
||||
relevant: true,
|
||||
price_impact: {
|
||||
asset: 'ETH',
|
||||
m5: -1.9,
|
||||
m15: -3.1,
|
||||
m1h: -4.2,
|
||||
price_at_post: 1920,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
text: "Met with many great business leaders today. Discussed the future of America's economy. Jobs are coming back FAST. Nobody builds like Trump!",
|
||||
source: 'truth',
|
||||
published_at: new Date(Date.now() - 1000 * 60 * 120).toISOString(),
|
||||
sentiment: 'neutral',
|
||||
ai_confidence: 42,
|
||||
relevant: false,
|
||||
price_impact: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
text: 'Crypto regulations will be ELIMINATED under my watch. No more Biden weaponization of the SEC against Bitcoin holders. We will have the most CRYPTO FRIENDLY government in history!',
|
||||
source: 'truth',
|
||||
published_at: new Date(Date.now() - 1000 * 60 * 210).toISOString(),
|
||||
sentiment: 'bullish',
|
||||
ai_confidence: 88,
|
||||
relevant: true,
|
||||
price_impact: {
|
||||
asset: 'BTC',
|
||||
m5: 0.9,
|
||||
m15: 2.1,
|
||||
m1h: 2.7,
|
||||
price_at_post: 91500,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
interface PostCardsProps {
|
||||
posts?: TrumpPost[]
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
export default function PostCards({ posts = MOCK_POSTS }: PostCardsProps) {
|
||||
const t = useTranslations('common')
|
||||
const { selectedPostId, setSelectedPost } = useDashboardStore()
|
||||
const selectedRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-scroll selected card into view
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && scrollRef.current) {
|
||||
selectedRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
|
||||
}
|
||||
}, [selectedPostId])
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-[#555555]">
|
||||
Posts · {posts.length}
|
||||
</span>
|
||||
{selectedPostId && (
|
||||
<button
|
||||
onClick={() => setSelectedPost(null)}
|
||||
className="text-[11px] text-[#f97316] hover:text-[#fb923c] transition-colors"
|
||||
>
|
||||
clear selection ×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontally scrollable row */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-3 overflow-x-auto pb-2"
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
{posts.map((post) => {
|
||||
const isSelected = selectedPostId === post.id
|
||||
const impact = post.price_impact
|
||||
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
ref={isSelected ? selectedRef : null}
|
||||
onClick={() => setSelectedPost(isSelected ? null : post.id)}
|
||||
className={`shrink-0 w-[200px] bg-[#0a0a0a] border rounded-[10px] p-3 cursor-pointer transition-colors hover:bg-[#0d0d0d] ${
|
||||
isSelected ? 'border-[#f97316]' : 'border-[#141414]'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={post.source} />
|
||||
<Badge variant={post.sentiment} />
|
||||
</div>
|
||||
<span className="text-[10px] text-[#555555]">{timeAgo(post.published_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<p className="text-[11px] text-[#e2e8f0] leading-relaxed mb-2 line-clamp-3">
|
||||
{post.text.slice(0, 90)}
|
||||
{post.text.length > 90 ? '…' : ''}
|
||||
</p>
|
||||
|
||||
{/* Confidence bar */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-[#555555]">AI</span>
|
||||
<span className="text-[10px] text-[#818cf8]">{post.ai_confidence}%</span>
|
||||
</div>
|
||||
<div className="h-[2px] bg-[#141414] rounded-full">
|
||||
<div
|
||||
className="h-full bg-[#818cf8] rounded-full"
|
||||
style={{ width: `${post.ai_confidence}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price impact */}
|
||||
{impact ? (
|
||||
<span
|
||||
className={`text-[11px] font-medium ${
|
||||
impact.m1h >= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{formatPct(impact.m1h)} 1h
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-[#333333]">{t('neutral')}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { MOCK_POSTS }
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useConnectModal } from '@rainbow-me/rainbowkit'
|
||||
import { shortenAddress } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ key: 'overview', href: '' },
|
||||
{ key: 'posts', href: '/posts' },
|
||||
{ key: 'trades', href: '/trades' },
|
||||
{ key: 'analytics', href: '/analytics' },
|
||||
] as const
|
||||
|
||||
interface NavbarProps {
|
||||
locale: string
|
||||
}
|
||||
|
||||
export default function Navbar({ locale }: NavbarProps) {
|
||||
const t = useTranslations('nav')
|
||||
const pathname = usePathname()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { openConnectModal } = useConnectModal()
|
||||
|
||||
function isActive(href: string) {
|
||||
const full = `/${locale}${href}`
|
||||
if (href === '') return pathname === `/${locale}` || pathname === `/${locale}/`
|
||||
return pathname.startsWith(full)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 h-14 bg-[#000000] border-b border-[#141414] flex items-center px-6">
|
||||
{/* Logo */}
|
||||
<Link href={`/${locale}`} className="flex items-center gap-2 mr-10 shrink-0">
|
||||
<span className="text-[#f97316] font-bold text-lg leading-none">TS</span>
|
||||
<span className="text-white font-medium text-sm">TrumpSignal</span>
|
||||
</Link>
|
||||
|
||||
{/* Center nav */}
|
||||
<div className="flex items-center gap-6 flex-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={`/${locale}${item.href}`}
|
||||
className={`text-[13px] transition-colors ${
|
||||
isActive(item.href) ? 'text-[#f97316]' : 'text-[#555555] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button className="text-[13px] text-[#555555] hover:text-white border border-[#141414] rounded-full px-3 py-1 transition-colors">
|
||||
{t('language')} ▾
|
||||
</button>
|
||||
|
||||
{isConnected && address ? (
|
||||
<button className="text-[13px] bg-[#141414] text-white border border-[#1a1a1a] rounded-full px-3 py-1">
|
||||
{shortenAddress(address)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={openConnectModal}
|
||||
className="text-[13px] bg-[#f97316] text-black font-medium rounded-full px-4 py-1.5 hover:bg-[#fb923c] transition-colors"
|
||||
>
|
||||
{t('connectWallet')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { BotTrade } from '@/types'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { formatPrice, formatHold } from '@/lib/utils'
|
||||
|
||||
const MOCK_TRADES: BotTrade[] = [
|
||||
{
|
||||
id: 1,
|
||||
asset: 'BTC',
|
||||
side: 'long',
|
||||
entry_price: 92800,
|
||||
exit_price: 95100,
|
||||
pnl_usd: 2300,
|
||||
hold_seconds: 52 * 60,
|
||||
trigger_post_id: 1,
|
||||
opened_at: new Date(Date.now() - 1000 * 60 * 80).toISOString(),
|
||||
closed_at: new Date(Date.now() - 1000 * 60 * 28).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
asset: 'ETH',
|
||||
side: 'short',
|
||||
entry_price: 1920,
|
||||
exit_price: 1847,
|
||||
pnl_usd: 1460,
|
||||
hold_seconds: 34 * 60,
|
||||
trigger_post_id: 2,
|
||||
opened_at: new Date(Date.now() - 1000 * 60 * 120).toISOString(),
|
||||
closed_at: new Date(Date.now() - 1000 * 60 * 86).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
asset: 'BTC',
|
||||
side: 'long',
|
||||
entry_price: 91500,
|
||||
exit_price: 90200,
|
||||
pnl_usd: -1300,
|
||||
hold_seconds: 22 * 60,
|
||||
trigger_post_id: 4,
|
||||
opened_at: new Date(Date.now() - 1000 * 60 * 240).toISOString(),
|
||||
closed_at: new Date(Date.now() - 1000 * 60 * 218).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
asset: 'ETH',
|
||||
side: 'long',
|
||||
entry_price: 1780,
|
||||
exit_price: 1840,
|
||||
pnl_usd: 900,
|
||||
hold_seconds: 8 * 60,
|
||||
trigger_post_id: 1,
|
||||
opened_at: new Date(Date.now() - 1000 * 60 * 360).toISOString(),
|
||||
closed_at: new Date(Date.now() - 1000 * 60 * 352).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
asset: 'BTC',
|
||||
side: 'short',
|
||||
entry_price: 93400,
|
||||
exit_price: 91800,
|
||||
pnl_usd: 3200,
|
||||
hold_seconds: 18 * 60,
|
||||
trigger_post_id: 2,
|
||||
opened_at: new Date(Date.now() - 1000 * 60 * 500).toISOString(),
|
||||
closed_at: new Date(Date.now() - 1000 * 60 * 482).toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
interface TradesTableProps {
|
||||
trades?: BotTrade[]
|
||||
}
|
||||
|
||||
export default function TradesTable({ trades = MOCK_TRADES }: TradesTableProps) {
|
||||
const t = useTranslations('trades')
|
||||
|
||||
return (
|
||||
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[#141414]">
|
||||
<p className="text-[13px] font-medium text-white">{t('title')}</p>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[#050505]">
|
||||
{[
|
||||
t('asset'),
|
||||
t('side'),
|
||||
t('entry'),
|
||||
t('exit'),
|
||||
t('pnl'),
|
||||
t('hold'),
|
||||
t('trigger'),
|
||||
].map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-[#555555] font-medium"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((trade) => (
|
||||
<tr
|
||||
key={trade.id}
|
||||
className="border-b border-[#141414] hover:bg-[#0d0d0d] transition-colors"
|
||||
>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-[13px] font-medium text-white">{trade.asset}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<Badge variant={trade.side} />
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-[13px] text-[#e2e8f0]">{formatPrice(trade.entry_price)}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-[13px] text-[#e2e8f0]">{formatPrice(trade.exit_price)}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span
|
||||
className={`text-[13px] font-medium ${
|
||||
trade.pnl_usd >= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{trade.pnl_usd >= 0 ? '+' : ''}${trade.pnl_usd.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-[13px] text-[#555555]">{formatHold(trade.hold_seconds)}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-[12px] text-[#818cf8] hover:text-[#a5b4fc] cursor-pointer">
|
||||
#{trade.trigger_post_id}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { MOCK_TRADES }
|
||||
@@ -0,0 +1,36 @@
|
||||
type BadgeVariant = 'bullish' | 'bearish' | 'neutral' | 'long' | 'short' | 'x' | 'truth'
|
||||
|
||||
interface BadgeProps {
|
||||
variant: BadgeVariant
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
bullish: 'bg-[#0d2e1a] text-[#4ade80] border border-[#1a4a2a]',
|
||||
bearish: 'bg-[#2e0d0d] text-[#ef4444] border border-[#4a1a1a]',
|
||||
neutral: 'bg-[#141414] text-[#555555] border border-[#1e1e1e]',
|
||||
long: 'bg-[#0d2e1a] text-[#4ade80] border border-[#1a4a2a]',
|
||||
short: 'bg-[#2e0d0d] text-[#ef4444] border border-[#4a1a1a]',
|
||||
x: 'bg-[#141414] text-white border border-[#222222]',
|
||||
truth: 'bg-[#1a1a2e] text-[#818cf8] border border-[#2a2a4a]',
|
||||
}
|
||||
|
||||
const variantLabels: Record<BadgeVariant, string> = {
|
||||
bullish: 'Bullish',
|
||||
bearish: 'Bearish',
|
||||
neutral: 'Neutral',
|
||||
long: 'Long',
|
||||
short: 'Short',
|
||||
x: 'X',
|
||||
truth: 'Truth',
|
||||
}
|
||||
|
||||
export default function Badge({ variant, children }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${variantStyles[variant]}`}
|
||||
>
|
||||
{children ?? variantLabels[variant]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Card({ children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
interface PillProps {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Pill({ active, onClick, children }: PillProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={
|
||||
`rounded-full px-3 py-1 text-xs transition-colors ` +
|
||||
(active
|
||||
? 'bg-[#141414] text-white border border-[#222222]'
|
||||
: 'text-[#555555] border border-[#141414] hover:text-white')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getRequestConfig } from 'next-intl/server'
|
||||
|
||||
export const locales = ['en', 'ar', 'pt-BR', 'zh', 'ja'] as const
|
||||
export type Locale = (typeof locales)[number]
|
||||
export const defaultLocale: Locale = 'en'
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const locale = (await requestLocale) ?? defaultLocale
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`./messages/${locale}.json`)).default,
|
||||
}
|
||||
})
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import type { TrumpPost, Candle, BotTrade, BotPerformance } from '@/types'
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
|
||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}/api${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
})
|
||||
if (!res.ok) throw new Error(`API error ${res.status}: ${path}`)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getPosts(limit = 20, page = 1): Promise<TrumpPost[]> {
|
||||
return fetchJson<TrumpPost[]>(`/posts?limit=${limit}&page=${page}`)
|
||||
}
|
||||
|
||||
export async function getPost(id: number): Promise<TrumpPost> {
|
||||
return fetchJson<TrumpPost>(`/posts/${id}`)
|
||||
}
|
||||
|
||||
export async function getPrices(asset: 'BTC' | 'ETH', tf: string): Promise<Candle[]> {
|
||||
return fetchJson<Candle[]>(`/prices/${asset}?tf=${tf}`)
|
||||
}
|
||||
|
||||
export async function getTrades(limit = 20, page = 1): Promise<BotTrade[]> {
|
||||
return fetchJson<BotTrade[]>(`/trades?limit=${limit}&page=${page}`)
|
||||
}
|
||||
|
||||
export async function getPerformance(): Promise<BotPerformance> {
|
||||
return fetchJson<BotPerformance>(`/performance`)
|
||||
}
|
||||
|
||||
export async function subscribe(
|
||||
wallet: string,
|
||||
signature: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchJson<{ success: boolean; message: string }>(`/subscribe`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ wallet, signature }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUser(
|
||||
wallet: string,
|
||||
): Promise<{ wallet_address: string; active: boolean; subscribed_at: string | null; hl_api_key_set: boolean; trades: BotTrade[] }> {
|
||||
return fetchJson<{ wallet_address: string; active: boolean; subscribed_at: string | null; hl_api_key_set: boolean; trades: BotTrade[] }>(
|
||||
`/user/${wallet}`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000'
|
||||
|
||||
type PriceUpdate = { type: 'price'; asset: 'BTC' | 'ETH'; price: number }
|
||||
type PostUpdate = { type: 'new_post'; post: object }
|
||||
type WsMessage = PriceUpdate | PostUpdate
|
||||
|
||||
interface Handlers {
|
||||
onPrice?: (asset: 'BTC' | 'ETH', price: number) => void
|
||||
onNewPost?: (post: object) => void
|
||||
}
|
||||
|
||||
export function usePriceSocket(handlers: Handlers) {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const handlersRef = useRef(handlers)
|
||||
handlersRef.current = handlers
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const ws = new WebSocket(`${WS_URL}/ws/prices`)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg: WsMessage = JSON.parse(e.data)
|
||||
if (msg.type === 'price' && handlersRef.current.onPrice) {
|
||||
handlersRef.current.onPrice(msg.asset, msg.price)
|
||||
} else if (msg.type === 'new_post' && handlersRef.current.onNewPost) {
|
||||
handlersRef.current.onNewPost(msg.post)
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setTimeout(connect, 3000)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
connect()
|
||||
return () => {
|
||||
wsRef.current?.close()
|
||||
}
|
||||
}, [connect])
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function formatPrice(n: number): string {
|
||||
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
export function formatPct(n: number): string {
|
||||
const sign = n >= 0 ? '+' : ''
|
||||
return `${sign}${n.toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function formatHold(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h === 0) return `${m}m`
|
||||
if (m === 0) return `${h}h`
|
||||
return `${h}h ${m}m`
|
||||
}
|
||||
|
||||
export function shortenAddress(addr: string): string {
|
||||
if (addr.length < 10) return addr
|
||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
|
||||
}
|
||||
|
||||
export function sentimentColor(s: string): string {
|
||||
switch (s) {
|
||||
case 'bullish':
|
||||
return '#4ade80'
|
||||
case 'bearish':
|
||||
return '#ef4444'
|
||||
default:
|
||||
return '#555555'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createConfig, http } from 'wagmi'
|
||||
import { mainnet } from 'wagmi/chains'
|
||||
import { metaMask } from 'wagmi/connectors'
|
||||
|
||||
export const chains = [mainnet] as const
|
||||
|
||||
export const config = createConfig({
|
||||
chains,
|
||||
connectors: [metaMask()],
|
||||
transports: {
|
||||
[mainnet.id]: http(),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"nav": {
|
||||
"overview": "Overview",
|
||||
"posts": "Posts",
|
||||
"trades": "Trades",
|
||||
"analytics": "Analytics",
|
||||
"settings": "Settings",
|
||||
"connectWallet": "Connect wallet",
|
||||
"language": "EN"
|
||||
},
|
||||
"kpi": {
|
||||
"btcPrice": "BTC Price",
|
||||
"ethPrice": "ETH Price",
|
||||
"postsTracked": "Posts Tracked",
|
||||
"avgMove": "Avg Move"
|
||||
},
|
||||
"bot": {
|
||||
"title": "Bot Performance",
|
||||
"winRate": "Win Rate",
|
||||
"netPnl": "Net PnL",
|
||||
"totalTrades": "Trades",
|
||||
"avgHold": "Avg Hold",
|
||||
"connectCta": "Let our AI trade for you",
|
||||
"connectDesc": "Connect your wallet to enable automated trading signals based on Trump's social posts.",
|
||||
"subscribeBtn": "Subscribe — $99/mo",
|
||||
"settingsTitle": "Bot Settings",
|
||||
"apiKeyLabel": "API Key",
|
||||
"apiKeyPlaceholder": "Paste your exchange API key",
|
||||
"connectWalletFree": "Connect wallet — free",
|
||||
"activeStatus": "Active",
|
||||
"saveKey": "Save",
|
||||
"period": "30d"
|
||||
},
|
||||
"trades": {
|
||||
"title": "Recent Bot Trades",
|
||||
"asset": "Asset",
|
||||
"side": "Side",
|
||||
"entry": "Entry",
|
||||
"exit": "Exit",
|
||||
"pnl": "PnL",
|
||||
"hold": "Hold",
|
||||
"trigger": "Trigger"
|
||||
},
|
||||
"posts": {
|
||||
"title": "Trump Posts",
|
||||
"sentimentLabel": "Sentiment",
|
||||
"sourceLabel": "Source",
|
||||
"confidenceLabel": "AI Confidence",
|
||||
"impactLabel": "Price Impact",
|
||||
"comingSoon": "Full post feed coming soon."
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"comingSoon": "Analytics coming soon."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"connectRequired": "Connect your wallet to access settings.",
|
||||
"comingSoon": "Settings coming soon."
|
||||
},
|
||||
"common": {
|
||||
"bullish": "Bullish",
|
||||
"bearish": "Bearish",
|
||||
"neutral": "Neutral",
|
||||
"loading": "Loading...",
|
||||
"comingSoon": "Coming soon"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import createMiddleware from 'next-intl/middleware'
|
||||
import { locales, defaultLocale } from './i18n'
|
||||
|
||||
export default createMiddleware({
|
||||
locales,
|
||||
defaultLocale,
|
||||
})
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)' ,
|
||||
],
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,10 @@
|
||||
import createNextIntlPlugin from 'next-intl/plugin'
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./i18n.ts')
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
export default withNextIntl(nextConfig)
|
||||
Generated
+13479
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "trumpsignal",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.5",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"next-intl": "^3.15.3",
|
||||
"lightweight-charts": "^4.1.3",
|
||||
"wagmi": "^2.10.5",
|
||||
"viem": "^2.17.0",
|
||||
"@rainbow-me/rainbowkit": "^2.1.3",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"zustand": "^4.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"typescript": "^5",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"postcss": "^8",
|
||||
"autoprefixer": "^10",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface DashboardState {
|
||||
selectedPostId: number | null
|
||||
asset: 'BTC' | 'ETH'
|
||||
timeframe: '5m' | '15m' | '1H' | '4H' | '1D' | '1W'
|
||||
walletAddress: string | null
|
||||
isSubscribed: boolean
|
||||
livePrices: { BTC: number | null; ETH: number | null }
|
||||
setSelectedPost: (id: number | null) => void
|
||||
setAsset: (asset: 'BTC' | 'ETH') => void
|
||||
setTimeframe: (tf: '5m' | '15m' | '1H' | '4H' | '1D' | '1W') => void
|
||||
setWallet: (address: string | null) => void
|
||||
setSubscribed: (subscribed: boolean) => void
|
||||
setLivePrice: (asset: 'BTC' | 'ETH', price: number) => void
|
||||
}
|
||||
|
||||
export const useDashboardStore = create<DashboardState>((set) => ({
|
||||
selectedPostId: null,
|
||||
asset: 'BTC',
|
||||
timeframe: '4H',
|
||||
walletAddress: null,
|
||||
isSubscribed: false,
|
||||
livePrices: { BTC: null, ETH: null },
|
||||
setSelectedPost: (id) => set({ selectedPostId: id }),
|
||||
setAsset: (asset) => set({ asset }),
|
||||
setTimeframe: (timeframe) => set({ timeframe }),
|
||||
setWallet: (walletAddress) => set({ walletAddress }),
|
||||
setSubscribed: (isSubscribed) => set({ isSubscribed }),
|
||||
setLivePrice: (asset, price) =>
|
||||
set((s) => ({ livePrices: { ...s.livePrices, [asset]: price } })),
|
||||
}))
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'bg-base': '#000000',
|
||||
'bg-card': '#0a0a0a',
|
||||
'bg-deep': '#050505',
|
||||
'bg-hover': '#0d0d0d',
|
||||
'bg-input': '#060606',
|
||||
'border-default': '#141414',
|
||||
'border-hover': '#222222',
|
||||
'text-primary': '#ffffff',
|
||||
'text-secondary': '#e2e8f0',
|
||||
'text-muted': '#555555',
|
||||
'text-faint': '#333333',
|
||||
accent: '#f97316',
|
||||
'accent-hover': '#fb923c',
|
||||
green: '#4ade80',
|
||||
red: '#ef4444',
|
||||
purple: '#818cf8',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface TrumpPost {
|
||||
id: number
|
||||
text: string
|
||||
source: 'x' | 'truth'
|
||||
published_at: string
|
||||
sentiment: 'bullish' | 'bearish' | 'neutral'
|
||||
signal: 'buy' | 'sell' | 'short' | 'hold' | null
|
||||
ai_confidence: number
|
||||
ai_reasoning: string | null
|
||||
relevant: boolean
|
||||
price_impact: {
|
||||
asset: 'BTC' | 'ETH'
|
||||
m5: number
|
||||
m15: number
|
||||
m1h: number
|
||||
price_at_post: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface Candle {
|
||||
time: number
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
volume: number
|
||||
}
|
||||
|
||||
export interface BotTrade {
|
||||
id: number
|
||||
asset: 'BTC' | 'ETH'
|
||||
side: 'long' | 'short'
|
||||
entry_price: number
|
||||
exit_price: number
|
||||
pnl_usd: number
|
||||
hold_seconds: number
|
||||
trigger_post_id: number
|
||||
opened_at: string
|
||||
closed_at: string
|
||||
}
|
||||
|
||||
export interface BotPerformance {
|
||||
period_days: number
|
||||
total_trades: number
|
||||
win_rate: number
|
||||
net_pnl_usd: number
|
||||
avg_hold_seconds: number
|
||||
max_drawdown_pct: number
|
||||
}
|
||||
Reference in New Issue
Block a user