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:
GifariKemal
2026-02-09 08:57:50 +07:00
parent 62c24ccf76
commit a3d5d654c4
9 changed files with 499 additions and 175 deletions
+2 -2
View File
@@ -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
View File
@@ -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
+11 -2
View File
@@ -686,13 +686,22 @@ 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")
return True, ExitReason.TREND_REVERSAL, f"[EARLY CUT] Loss ${abs(current_profit):.2f} + momentum {momentum:.0f} - cutting to preserve daily limit"
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
# If SL is hit, close the trade immediately
+3 -16
View File
@@ -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(
"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",
!isBlocker && !isNotEvaluated && "row-hover"
)}>
{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")}>
<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"
)}
>
{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" />
)}
<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>
</TooltipContent>
</Tooltip>
</TooltipTrigger>
<TooltipContent side="right">
<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 -1
View File
@@ -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);