feat: early cut grace period + AI assistant card + filter UX cleanup
- Add 15-min grace period before early cut (wait 1 M15 candle to develop) - Replace equity chart with AI Assistant card (real-time insights in Indonesian) - Merge filter toggles into EntryFilterCard (remove separate FiltersConfigCard) - Fix filter config API URL typo (8001 → 8000) - Fix tooltip blocking switch clicks (move Switch outside TooltipTrigger) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,7 +57,7 @@
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"updated_at": "2026-02-09T08:05:00+07:00",
|
||||
"updated_at": "2026-02-09T08:28:17.078826+07:00",
|
||||
"version": "1.0"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -39,8 +39,8 @@ services:
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
volumes:
|
||||
# Mount data/ so API can read bot_status.json written by the bot on host
|
||||
- ./data:/app/data:ro
|
||||
# Mount data/ for reading bot_status.json and writing filter_config.json
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -686,12 +686,21 @@ class SmartRiskManager:
|
||||
current_hour = now.hour
|
||||
|
||||
# Early cut: If loss > 30% of max and momentum negative, cut early
|
||||
# GRACE PERIOD: Wait at least 1 M15 candle (15 min) before early cut
|
||||
# Intra-candle moves are noise — let the trade develop on its timeframe
|
||||
trade_age_seconds = (now - guard.entry_time).total_seconds()
|
||||
trade_age_minutes = trade_age_seconds / 60
|
||||
|
||||
if current_profit < 0:
|
||||
loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
|
||||
|
||||
# Cut early if momentum is against us AND loss is significant
|
||||
# BUT only after grace period (15 min = 1 M15 candle)
|
||||
if momentum < -50 and loss_percent_of_max >= 30: # #24B: relaxed from -30 (backtest +$125)
|
||||
logger.info(f"[EARLY CUT] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + weak momentum ({momentum:.0f}) - CUTTING EARLY")
|
||||
if trade_age_minutes < 15:
|
||||
logger.info(f"[GRACE] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + momentum ({momentum:.0f}) — holding {trade_age_minutes:.1f}m/{15}m grace period")
|
||||
else:
|
||||
logger.info(f"[EARLY CUT] Loss ${abs(current_profit):.2f} ({loss_percent_of_max:.0f}%) + weak momentum ({momentum:.0f}) - CUTTING EARLY (age: {trade_age_minutes:.0f}m)")
|
||||
return True, ExitReason.TREND_REVERSAL, f"[EARLY CUT] Loss ${abs(current_profit):.2f} + momentum {momentum:.0f} - cutting to preserve daily limit"
|
||||
|
||||
# NOTE: Smart Hold REMOVED - no more holding losers hoping for golden time
|
||||
|
||||
@@ -13,12 +13,11 @@ import {
|
||||
PositionsCard,
|
||||
LogCard,
|
||||
PriceChart,
|
||||
EquityChart,
|
||||
BotStatusCard,
|
||||
EntryFilterCard,
|
||||
PerformanceCard,
|
||||
ModelCard,
|
||||
FiltersConfigCard,
|
||||
AssistantCard,
|
||||
} from "@/components/dashboard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -69,7 +68,7 @@ function ErrorDisplay({ message }: { message: string }) {
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data, loading, error, dataAge } = useTradingData();
|
||||
const visible = useStaggerEntry(17, 40);
|
||||
const visible = useStaggerEntry(15, 40);
|
||||
|
||||
const now = new Date();
|
||||
const wibTime = now.toLocaleTimeString("en-US", {
|
||||
@@ -204,10 +203,7 @@ export default function Dashboard() {
|
||||
<PriceChart data={data.priceHistory} />
|
||||
</div>
|
||||
<div className={cn("stagger-enter col-span-2 min-h-0", visible[10] && "visible")}>
|
||||
<EquityChart
|
||||
equityData={data.equityHistory}
|
||||
balanceData={data.balanceHistory}
|
||||
/>
|
||||
<AssistantCard data={data} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -237,15 +233,6 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5: Filter Controls */}
|
||||
<div className="flex-[1.4] min-h-0 grid grid-cols-2 gap-1.5">
|
||||
<div className={cn("stagger-enter h-full", visible[15] && "visible")}>
|
||||
<FiltersConfigCard />
|
||||
</div>
|
||||
<div className={cn("stagger-enter h-full", visible[16] && "visible")}>
|
||||
{/* Placeholder for future card */}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Bot, TrendingUp, TrendingDown, ShieldAlert, Clock, Zap,
|
||||
AlertTriangle, Coffee, CheckCircle2, XCircle, Minus,
|
||||
Activity, Target, BarChart3, Eye,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TradingStatus } from "@/types/trading";
|
||||
|
||||
type InsightType = "info" | "success" | "warning" | "danger";
|
||||
|
||||
interface Insight {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
type: InsightType;
|
||||
}
|
||||
|
||||
const typeStyles: Record<InsightType, string> = {
|
||||
info: "text-blue-400/90",
|
||||
success: "text-apple-green",
|
||||
warning: "text-orange-400",
|
||||
danger: "text-apple-red",
|
||||
};
|
||||
|
||||
const sessionNames: Record<string, string> = {
|
||||
sydney: "Sydney", tokyo: "Tokyo", london: "London",
|
||||
new_york: "New York", off_hours: "Off Hours",
|
||||
};
|
||||
|
||||
function generateInsights(data: TradingStatus): Insight[] {
|
||||
const insights: Insight[] = [];
|
||||
const hasPositions = data.positions && data.positions.length > 0;
|
||||
const sessionName = sessionNames[data.session?.toLowerCase()] || data.session;
|
||||
const regimeName = data.regime?.name || "unknown";
|
||||
const regimeConf = data.regime?.confidence ? (data.regime.confidence * 100).toFixed(0) : "?";
|
||||
const smcSignal = data.smc?.signal || "NONE";
|
||||
const smcConf = data.smc?.confidence ? (data.smc.confidence * 100).toFixed(0) : "0";
|
||||
const mlSignal = data.ml?.signal || "HOLD";
|
||||
const mlConf = data.ml?.confidence ? (data.ml.confidence * 100).toFixed(0) : "0";
|
||||
const h1Bias = data.h1Bias || "N/A";
|
||||
const dynThreshold = data.dynamicThreshold ? (data.dynamicThreshold * 100).toFixed(0) : "60";
|
||||
const spread = data.spread?.toFixed(1) || "?";
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 1. STATUS MARKET
|
||||
// ═══════════════════════════════════════════
|
||||
if (data.marketClose && !data.marketClose.marketOpen) {
|
||||
insights.push({
|
||||
icon: <Coffee className="h-3.5 w-3.5" />,
|
||||
text: "Market sedang tutup. Bot dalam mode standby — tidak ada analisis atau eksekusi. Menunggu market buka kembali.",
|
||||
type: "info",
|
||||
});
|
||||
return insights;
|
||||
}
|
||||
|
||||
if (data.marketClose?.nearWeekend) {
|
||||
const hrs = data.marketClose.hoursToWeekendClose;
|
||||
insights.push({
|
||||
icon: <AlertTriangle className="h-3.5 w-3.5" />,
|
||||
text: `Peringatan: Weekend close dalam ${hrs.toFixed(1)} jam (Sabtu 05:00 WIB). Bot akan menolak entry baru dan memastikan semua posisi ditutup sebelum market close.`,
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 2. RANGKUMAN SITUASI (top-level summary)
|
||||
// ═══════════════════════════════════════════
|
||||
if (hasPositions) {
|
||||
const totalProfit = data.positions.reduce((sum, p) => sum + p.profit, 0);
|
||||
const profitStr = totalProfit >= 0 ? `+$${totalProfit.toFixed(2)}` : `-$${Math.abs(totalProfit).toFixed(2)}`;
|
||||
insights.push({
|
||||
icon: <Activity className="h-3.5 w-3.5" />,
|
||||
text: `Sedang dalam posisi (${data.positions.length} trade aktif, total P/L: ${profitStr}). Bot memantau exit conditions setiap 5 detik.`,
|
||||
type: totalProfit >= 0 ? "success" : "warning",
|
||||
});
|
||||
} else {
|
||||
insights.push({
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
text: `Tidak ada posisi terbuka. Bot menganalisis market setiap candle M15 untuk mencari peluang entry.`,
|
||||
type: "info",
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 3. SESI & WAKTU
|
||||
// ═══════════════════════════════════════════
|
||||
if (data.isGoldenTime) {
|
||||
insights.push({
|
||||
icon: <Zap className="h-3.5 w-3.5" />,
|
||||
text: `Sesi ${sessionName} — GOLDEN TIME! Overlap London-NY menghasilkan volatilitas tertinggi. Lot size dinaikkan ${data.sessionMultiplier || 1}x. Peluang terbaik untuk entry.`,
|
||||
type: "success",
|
||||
});
|
||||
} else if (!data.canTrade) {
|
||||
const nextSession = data.session?.toLowerCase() === "off_hours" ? "Sydney (04:00 WIB)"
|
||||
: data.session?.toLowerCase() === "sydney" ? "London (14:00 WIB)"
|
||||
: "sesi berikutnya";
|
||||
insights.push({
|
||||
icon: <Clock className="h-3.5 w-3.5" />,
|
||||
text: `Sesi ${sessionName} — di luar jam trading aktif. Volume rendah, spread bisa melebar. Menunggu ${nextSession}.`,
|
||||
type: "info",
|
||||
});
|
||||
} else {
|
||||
const multiplierText = data.sessionMultiplier && data.sessionMultiplier > 1
|
||||
? ` Lot multiplier: ${data.sessionMultiplier}x.`
|
||||
: data.sessionMultiplier && data.sessionMultiplier < 1
|
||||
? ` SAFE MODE: lot dikurangi ${data.sessionMultiplier}x.`
|
||||
: "";
|
||||
insights.push({
|
||||
icon: <Clock className="h-3.5 w-3.5" />,
|
||||
text: `Sesi ${sessionName} aktif — market terbuka untuk trading.${multiplierText}`,
|
||||
type: "info",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.timeFilter?.isBlocked) {
|
||||
insights.push({
|
||||
icon: <AlertTriangle className="h-3.5 w-3.5" />,
|
||||
text: `Jam ${data.timeFilter.wibHour}:00 WIB diblokir oleh time filter (jam-jam dengan win rate rendah secara historis). Entry ditunda.`,
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 4. KONDISI MARKET (regime + spread + H1)
|
||||
// ═══════════════════════════════════════════
|
||||
const regimeDetail = regimeName.includes("trending")
|
||||
? `Market trending (HMM confidence ${regimeConf}%) — kondisi ideal. Harga bergerak terarah, sinyal lebih reliable.`
|
||||
: regimeName.includes("high_volatility") || regimeName.includes("volatile")
|
||||
? `Volatilitas tinggi (HMM ${regimeConf}%) — HATI-HATI! Pergerakan harga liar, SL bisa cepat tersentuh. Bot menaikkan spread tolerance.`
|
||||
: regimeName.includes("low_volatility") || regimeName.includes("ranging")
|
||||
? `Volatilitas rendah / ranging (HMM ${regimeConf}%) — market diam, peluang kecil. Bot menunggu breakout atau perubahan regime.`
|
||||
: `Regime: ${regimeName} (HMM ${regimeConf}%).`;
|
||||
|
||||
insights.push({
|
||||
icon: regimeName.includes("trending") ? <TrendingUp className="h-3.5 w-3.5" />
|
||||
: regimeName.includes("volatile") || regimeName.includes("high") ? <AlertTriangle className="h-3.5 w-3.5" />
|
||||
: <BarChart3 className="h-3.5 w-3.5" />,
|
||||
text: regimeDetail,
|
||||
type: regimeName.includes("trending") ? "success"
|
||||
: regimeName.includes("volatile") || regimeName.includes("high") ? "warning"
|
||||
: "info",
|
||||
});
|
||||
|
||||
// H1 Bias detail
|
||||
if (h1Bias && h1Bias !== "N/A") {
|
||||
const h1Text = h1Bias === "BULLISH"
|
||||
? `H1 Bias: BULLISH — harga di atas EMA20 H1, tren naik jangka menengah. Entry BUY diizinkan.`
|
||||
: h1Bias === "BEARISH"
|
||||
? `H1 Bias: BEARISH — harga di bawah EMA20 H1, tren turun jangka menengah. Entry SELL diizinkan.`
|
||||
: `H1 Bias: NEUTRAL — harga dekat EMA20 H1, tidak ada tren jelas. Entry di-hold sampai arah terbentuk.`;
|
||||
insights.push({
|
||||
icon: h1Bias === "BULLISH" ? <TrendingUp className="h-3.5 w-3.5" />
|
||||
: h1Bias === "BEARISH" ? <TrendingDown className="h-3.5 w-3.5" />
|
||||
: <Minus className="h-3.5 w-3.5" />,
|
||||
text: h1Text,
|
||||
type: h1Bias === "NEUTRAL" ? "warning" : "info",
|
||||
});
|
||||
}
|
||||
|
||||
// Spread
|
||||
insights.push({
|
||||
icon: <Activity className="h-3.5 w-3.5" />,
|
||||
text: `Spread saat ini: ${spread} pips. ${parseFloat(spread) > 40 ? "Cukup lebar — entry mungkin ditunda." : parseFloat(spread) > 25 ? "Normal." : "Ketat — kondisi bagus."}`,
|
||||
type: parseFloat(spread) > 40 ? "warning" : "info",
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 5. ANALISIS SINYAL
|
||||
// ═══════════════════════════════════════════
|
||||
if (smcSignal !== "NONE" && smcSignal !== "HOLD") {
|
||||
const smcReason = data.smc?.reason || "";
|
||||
const mlAgrees = mlSignal === smcSignal;
|
||||
const mlAboveThreshold = data.ml?.confidence && data.ml.confidence * 100 >= parseFloat(dynThreshold);
|
||||
|
||||
if (mlAgrees && mlAboveThreshold) {
|
||||
insights.push({
|
||||
icon: <Zap className="h-3.5 w-3.5" />,
|
||||
text: `SINYAL KUAT: SMC ${smcSignal} (${smcConf}%) + ML ${mlSignal} (${mlConf}%) — keduanya sepakat dan di atas threshold ${dynThreshold}%.${smcReason ? ` SMC: ${smcReason}.` : ""} Tinggal filter lain terpenuhi untuk entry.`,
|
||||
type: "success",
|
||||
});
|
||||
} else if (mlAgrees && !mlAboveThreshold) {
|
||||
insights.push({
|
||||
icon: <Target className="h-3.5 w-3.5" />,
|
||||
text: `SMC ${smcSignal} (${smcConf}%) & ML setuju ${mlSignal}, tapi confidence ML (${mlConf}%) masih di bawah threshold (${dynThreshold}%). Perlu lebih yakin.`,
|
||||
type: "warning",
|
||||
});
|
||||
} else {
|
||||
insights.push({
|
||||
icon: <Minus className="h-3.5 w-3.5" />,
|
||||
text: `Sinyal konflik — SMC: ${smcSignal} (${smcConf}%) vs ML: ${mlSignal} (${mlConf}%). Bot menunggu kedua model sinkron sebelum entry.${smcReason ? ` SMC: ${smcReason}.` : ""}`,
|
||||
type: "info",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
insights.push({
|
||||
icon: <Minus className="h-3.5 w-3.5" />,
|
||||
text: `Belum ada sinyal — SMC: ${smcSignal}, ML: ${mlSignal} (${mlConf}%). Menunggu setup terbentuk di candle M15 berikutnya.`,
|
||||
type: "info",
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 6. POSISI TERBUKA (detail)
|
||||
// ═══════════════════════════════════════════
|
||||
if (hasPositions) {
|
||||
for (const pos of data.positions) {
|
||||
const detail = data.positionDetails?.find((d) => d.ticket === pos.ticket);
|
||||
const profitStr = pos.profit >= 0 ? `+$${pos.profit.toFixed(2)}` : `-$${Math.abs(pos.profit).toFixed(2)}`;
|
||||
const dir = pos.type;
|
||||
|
||||
const ageMinutes = detail?.tradeHours ? detail.tradeHours * 60 : 0;
|
||||
const ageText = ageMinutes > 0
|
||||
? ageMinutes < 60 ? `${ageMinutes.toFixed(0)}m` : `${(ageMinutes / 60).toFixed(1)}j`
|
||||
: "";
|
||||
const momentumVal = detail?.momentum ?? 0;
|
||||
const tpProb = detail?.tpProbability ?? 0;
|
||||
const peakProfit = detail?.peakProfit ?? 0;
|
||||
const drawdown = detail?.drawdownFromPeak ?? 0;
|
||||
const reversalWarns = detail?.reversalWarnings ?? 0;
|
||||
|
||||
let analysis = "";
|
||||
if (pos.profit >= 20) {
|
||||
analysis = `Profit sangat baik! Trailing SL aktif mengunci keuntungan. Peak: $${peakProfit.toFixed(2)}, drawdown dari peak: $${drawdown.toFixed(2)}. TP probability: ${tpProb.toFixed(0)}%.`;
|
||||
} else if (pos.profit >= 10) {
|
||||
analysis = `Profit bagus — momentum ${momentumVal > 0 ? "positif" : "melemah"} (${momentumVal.toFixed(0)}). Peak profit: $${peakProfit.toFixed(2)}. ${tpProb > 50 ? "Peluang capai TP masih tinggi." : "Mulai pantau untuk ambil profit."}`;
|
||||
} else if (pos.profit >= 0) {
|
||||
analysis = `Masih floating ${profitStr} (${ageText}). Momentum: ${momentumVal.toFixed(0)}. ${ageMinutes < 15 ? "Masih dalam grace period 15 menit — biarkan trade berkembang." : "Memantau arah selanjutnya."}`;
|
||||
} else if (ageMinutes < 15) {
|
||||
analysis = `Loss ${profitStr} tapi masih GRACE PERIOD (${ageText}/15m). Early cut TIDAK aktif — memberi waktu 1 candle M15 untuk develop. Hard SL tetap jadi safety net.`;
|
||||
} else {
|
||||
analysis = `Loss ${profitStr} (${ageText}), momentum: ${momentumVal.toFixed(0)}. ${momentumVal < -50 ? "Momentum lemah — early cut bisa trigger kapan saja!" : "Momentum belum terlalu buruk, masih ada harapan recovery."}${reversalWarns > 0 ? ` Reversal warning: ${reversalWarns}x.` : ""}`;
|
||||
}
|
||||
|
||||
insights.push({
|
||||
icon: pos.profit >= 5 ? <TrendingUp className="h-3.5 w-3.5" />
|
||||
: pos.profit >= 0 ? <Clock className="h-3.5 w-3.5" />
|
||||
: pos.profit > -15 ? <AlertTriangle className="h-3.5 w-3.5" />
|
||||
: <ShieldAlert className="h-3.5 w-3.5" />,
|
||||
text: `📊 #${pos.ticket} ${dir} @ ${pos.priceOpen.toFixed(2)} — ${analysis}`,
|
||||
type: pos.profit >= 5 ? "success" : pos.profit >= 0 ? "info" : pos.profit > -15 ? "warning" : "danger",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 7. KENAPA TIDAK ENTRY (detail per filter)
|
||||
// ═══════════════════════════════════════════
|
||||
if (!hasPositions) {
|
||||
const filters = data.entryFilters || [];
|
||||
const blockers = filters.filter((f) => !f.passed && !f.detail?.includes("[DISABLED]"));
|
||||
const disabledFilters = filters.filter((f) => f.detail?.includes("[DISABLED]"));
|
||||
const passedCount = filters.filter((f) => f.passed).length;
|
||||
|
||||
if (blockers.length > 0) {
|
||||
insights.push({
|
||||
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||
text: `Entry diblokir oleh ${blockers.length} filter (${passedCount}/${filters.length} lolos). Filter pertama yang gagal: "${blockers[0].name}" — ${blockers[0].detail || "tidak memenuhi syarat"}. Bot tidak akan entry sampai SEMUA filter hijau.`,
|
||||
type: "warning",
|
||||
});
|
||||
} else if (filters.length > 0 && blockers.length === 0) {
|
||||
insights.push({
|
||||
icon: <CheckCircle2 className="h-3.5 w-3.5" />,
|
||||
text: `Semua ${filters.length} filter terpenuhi! Bot siap entry begitu ada sinyal valid dari SMC + ML.`,
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
|
||||
if (disabledFilters.length > 0) {
|
||||
const names = disabledFilters.map((f) => f.name).join(", ");
|
||||
insights.push({
|
||||
icon: <AlertTriangle className="h-3.5 w-3.5" />,
|
||||
text: `${disabledFilters.length} filter dinonaktifkan manual: ${names}. Filter ini di-bypass (auto-pass). Aktifkan kembali di panel Filters jika diperlukan.`,
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 8. RISK & MODAL
|
||||
// ═══════════════════════════════════════════
|
||||
const netPnl = (data.dailyProfit || 0) - (data.dailyLoss || 0);
|
||||
if (data.dailyLoss > 0 || data.dailyProfit > 0) {
|
||||
const pnlStr = netPnl >= 0 ? `+$${netPnl.toFixed(2)}` : `-$${Math.abs(netPnl).toFixed(2)}`;
|
||||
const remaining = data.riskMode?.remainingDailyRisk;
|
||||
const riskDetail = remaining !== undefined ? ` Sisa risk harian: $${remaining.toFixed(0)}.` : "";
|
||||
|
||||
if (netPnl < 0 && remaining !== undefined && remaining < 50) {
|
||||
insights.push({
|
||||
icon: <ShieldAlert className="h-3.5 w-3.5" />,
|
||||
text: `⚠️ P/L hari ini: ${pnlStr} (loss $${data.dailyLoss.toFixed(2)}, profit $${data.dailyProfit.toFixed(2)}).${riskDetail} Mendekati batas — bot sangat konservatif.`,
|
||||
type: "danger",
|
||||
});
|
||||
} else {
|
||||
insights.push({
|
||||
icon: netPnl >= 0 ? <TrendingUp className="h-3.5 w-3.5" /> : <AlertTriangle className="h-3.5 w-3.5" />,
|
||||
text: `P/L hari ini: ${pnlStr} (loss $${data.dailyLoss.toFixed(2)}, profit $${data.dailyProfit.toFixed(2)}).${riskDetail}`,
|
||||
type: netPnl >= 0 ? "success" : "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.riskMode) {
|
||||
const mode = data.riskMode.mode?.toUpperCase() || "NORMAL";
|
||||
if (mode !== "NORMAL") {
|
||||
insights.push({
|
||||
icon: <ShieldAlert className="h-3.5 w-3.5" />,
|
||||
text: `Risk mode: ${mode} — ${data.riskMode.reason}. Lot: ${data.riskMode.recommendedLot} (max ${data.riskMode.maxAllowedLot}).`,
|
||||
type: mode === "RECOVERY" || mode === "PROTECTIVE" ? "danger" : "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 9. PERFORMA BOT
|
||||
// ═══════════════════════════════════════════
|
||||
if (data.performance) {
|
||||
const p = data.performance;
|
||||
const uptimeText = p.uptimeHours < 1
|
||||
? `${(p.uptimeHours * 60).toFixed(0)} menit`
|
||||
: `${p.uptimeHours.toFixed(1)} jam`;
|
||||
insights.push({
|
||||
icon: <Bot className="h-3.5 w-3.5" />,
|
||||
text: `Bot aktif ${uptimeText}, loop ke-${p.loopCount}. Avg execution: ${p.avgExecutionMs.toFixed(0)}ms. Session trades: ${p.totalSessionTrades} (P/L: ${p.totalSessionProfit >= 0 ? "+" : ""}$${p.totalSessionProfit.toFixed(2)}).`,
|
||||
type: "info",
|
||||
});
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
|
||||
interface AssistantCardProps {
|
||||
data: TradingStatus;
|
||||
}
|
||||
|
||||
export function AssistantCard({ data }: AssistantCardProps) {
|
||||
const insights = generateInsights(data);
|
||||
const [wibTime, setWibTime] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setWibTime(
|
||||
new Date().toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
);
|
||||
};
|
||||
update();
|
||||
const interval = setInterval(update, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="glass h-full overflow-hidden flex flex-col accent-top-blue">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-1.5 uppercase tracking-wider text-blue-400">
|
||||
<Bot className="h-4 w-4" />
|
||||
Asisten Bot
|
||||
<span className="ml-auto text-[10px] font-normal normal-case tracking-normal text-muted-foreground/60 font-mono">
|
||||
{wibTime} WIB
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 min-h-0 overflow-auto pr-2">
|
||||
<div className="space-y-2">
|
||||
{insights.map((insight, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2 text-[11px] leading-[1.5]"
|
||||
>
|
||||
<span className={cn("mt-0.5 shrink-0", typeStyles[insight.type])}>
|
||||
{insight.icon}
|
||||
</span>
|
||||
<span className="text-foreground/80">{insight.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Last analysis timestamp */}
|
||||
<div className="mt-3 pt-2 border-t border-white/5 text-[10px] text-muted-foreground/40 flex items-center gap-1">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
Analisis terakhir: {data.timestamp
|
||||
? new Date(data.timestamp).toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
: wibTime
|
||||
} WIB
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,35 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Filter, Check, X, Minus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFilterConfig } from "@/hooks/use-filter-config";
|
||||
import type { EntryFilter } from "@/types/trading";
|
||||
|
||||
/* Map bot filter display name → filter_config.json key */
|
||||
const NAME_TO_KEY: Record<string, string> = {
|
||||
"Flash Crash Guard": "flash_crash_guard",
|
||||
"Regime Filter": "regime_filter",
|
||||
"Risk Check": "risk_check",
|
||||
"Session Filter": "session_filter",
|
||||
"Spread Check": "spread_check",
|
||||
"H1 Bias (#31B)": "h1_bias",
|
||||
"ML Confidence": "ml_confidence",
|
||||
"Signal Combination": "signal_combination",
|
||||
"Time Filter (#34A)": "time_filter",
|
||||
"Cooldown": "cooldown",
|
||||
"Existing Position": "existing_position",
|
||||
};
|
||||
|
||||
interface EntryFilterCardProps {
|
||||
filters: EntryFilter[];
|
||||
}
|
||||
|
||||
export function EntryFilterCard({ filters }: EntryFilterCardProps) {
|
||||
const { config, updating, toggleFilter } = useFilterConfig();
|
||||
|
||||
const passedCount = filters.filter((f) => f.passed).length;
|
||||
const totalCount = filters.length;
|
||||
const hasBlocker = filters.some((f) => !f.passed);
|
||||
@@ -44,33 +63,66 @@ export function EntryFilterCard({ filters }: EntryFilterCardProps) {
|
||||
{filters.map((filter, idx) => {
|
||||
const isNotEvaluated = firstBlockerIdx >= 0 && idx > firstBlockerIdx;
|
||||
const isBlocker = !filter.passed && idx === firstBlockerIdx;
|
||||
const isDisabled = filter.detail?.includes("[DISABLED]");
|
||||
|
||||
/* Resolve filter config key for toggle */
|
||||
const configKey = NAME_TO_KEY[filter.name];
|
||||
const filterEnabled = configKey && config
|
||||
? config.filters[configKey]?.enabled ?? true
|
||||
: true;
|
||||
|
||||
return (
|
||||
<Tooltip key={`${filter.name}-${idx}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn(
|
||||
<div
|
||||
key={`${filter.name}-${idx}`}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2 py-0.5 rounded text-sm",
|
||||
isBlocker && "bg-danger/8 border-l-2 border-l-apple-red",
|
||||
isNotEvaluated && "opacity-40",
|
||||
filter.passed && !isNotEvaluated && "border-l-2 border-l-apple-green/30",
|
||||
isDisabled && "border-l-2 border-l-orange-400/40",
|
||||
!isBlocker && !isNotEvaluated && "row-hover"
|
||||
)}>
|
||||
{isNotEvaluated ? (
|
||||
)}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<Minus className="h-3.5 w-3.5 text-orange-400 flex-shrink-0" />
|
||||
) : isNotEvaluated ? (
|
||||
<Minus className="h-3.5 w-3.5 text-muted-foreground/40 flex-shrink-0" />
|
||||
) : filter.passed ? (
|
||||
<Check className="h-3.5 w-3.5 text-apple-green flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-apple-red flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn("truncate flex-1", isBlocker ? "text-apple-red font-semibold" : filter.passed ? "text-foreground/80" : "text-muted-foreground")}>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(
|
||||
"truncate flex-1 cursor-default",
|
||||
isDisabled ? "text-orange-400/70" :
|
||||
isBlocker ? "text-apple-red font-semibold" :
|
||||
filter.passed ? "text-foreground/80" :
|
||||
"text-muted-foreground"
|
||||
)}>
|
||||
{filter.name}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p className="max-w-[220px]">{filter.detail || (filter.passed ? "Passed" : "Blocked")}</p>
|
||||
<p className="max-w-[220px]">
|
||||
{filter.detail || (filter.passed ? "Passed" : "Blocked")}
|
||||
{configKey && !filterEnabled && " — Filter disabled, auto-pass"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Toggle switch — outside tooltip so clicks work */}
|
||||
{configKey && config && (
|
||||
<Switch
|
||||
checked={filterEnabled}
|
||||
onCheckedChange={() => toggleFilter(configKey)}
|
||||
disabled={updating}
|
||||
className="shrink-0 scale-75 origin-right"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useFilterConfig } from "@/hooks/use-filter-config";
|
||||
import { AlertCircle, CheckCircle2, RefreshCcw } from "lucide-react";
|
||||
|
||||
export function FiltersConfigCard() {
|
||||
const { config, loading, error, updating, toggleFilter, refresh } = useFilterConfig();
|
||||
|
||||
if (loading && !config) {
|
||||
return (
|
||||
<Card className="glass">
|
||||
<CardHeader>
|
||||
<CardTitle>Entry Filters</CardTitle>
|
||||
<CardDescription>Loading...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
<Skeleton className="h-5 w-9 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="glass border-red-500/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-400">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Entry Filters
|
||||
</CardTitle>
|
||||
<CardDescription className="text-red-400/70">{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Retry
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const filters = Object.entries(config.filters);
|
||||
const enabledCount = filters.filter(([, f]) => f.enabled).length;
|
||||
|
||||
// Sort by name for consistent display
|
||||
const sortedFilters = filters.sort((a, b) => a[1].name.localeCompare(b[1].name));
|
||||
|
||||
return (
|
||||
<Card className="glass">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Entry Filters
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{enabledCount}/{filters.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Toggle filters on/off — updates live without bot restart
|
||||
</CardDescription>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={updating}
|
||||
className="p-2 hover:bg-white/5 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 ${updating ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{sortedFilters.map(([key, filter]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-start justify-between gap-4 py-2 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{filter.name}</span>
|
||||
{filter.enabled ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-400 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-orange-400 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{filter.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={filter.enabled}
|
||||
onCheckedChange={() => toggleFilter(key)}
|
||||
disabled={updating}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{config.metadata?.updated_at && (
|
||||
<div className="text-xs text-muted-foreground pt-2 border-t border-white/5">
|
||||
Last updated: {new Date(config.metadata.updated_at).toLocaleString('id-ID', {
|
||||
timeZone: 'Asia/Jakarta',
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short'
|
||||
})} WIB
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -16,4 +16,4 @@ export { EntryFilterCard } from "./entry-filter-card";
|
||||
export { PerformanceCard } from "./performance-card";
|
||||
export { ModelCard } from "./model-card";
|
||||
export { ModelDialog } from "./model-dialog";
|
||||
export { FiltersConfigCard } from "./filters-config-card";
|
||||
export { AssistantCard } from "./assistant-card";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { FilterConfigData, FilterUpdate } from '@/types/filters';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export function useFilterConfig() {
|
||||
const [config, setConfig] = useState<FilterConfigData | null>(null);
|
||||
|
||||
Reference in New Issue
Block a user