feat: FW-3 live-test fix wave — connector resume + i18n, hero tabs, activity chain, pro validation charts
- ConnectionBanner: surface only during an active reconnect loop (boot-state false alarm fix) - Trading-monitor wording replaces literal 'runtime' jargon across zh/ja/ko/ar - Hero: vertical centering + category-tab example library; About relocated to sidebar footer - Brand: ascending-candles mark in BrandMark/favicon/logo.svg (replaces VT lettermark + stale FIN-ANYTHING wordmark) - Dark theme: surface separation and border/muted-foreground contrast lift - Connector panel: /live/resume wired with a resume button; RunnerStatus internals fully i18n'd - Activity chain: coalesced xN steps with argument summaries, chronological order, sub-second durations - RunDetail: trades side-normalization fix + pnl/return/holding columns + filters/pagination/totals; code copy button; run-card validation rendered via ValidationPanel - Validation: Monte Carlo fan chart + Sharpe distribution histograms + walk-forward bars; validation.py emits sharpe samples and path envelopes - Compare: centered layout, rebase-to-100 overlay toggle, in-cell metric bars, swap button
This commit is contained in:
1 parent
d8c2a600c7
commit
8f40de5a5c
28 files changed
+1373
-360
No files matched your search
@@ -64,9 +64,15 @@ def monte_carlo_test(
|
||||
sharpe_count = 0
|
||||
dd_count = 0
|
||||
sim_sharpes = []
|
||||
# The full path matrix feeds the fan-chart payload; skip it for
|
||||
# pathological sizes so a huge run cannot balloon memory or the JSON.
|
||||
keep_paths = n_simulations * len(pnls) <= 2_000_000
|
||||
sim_equities = np.empty((n_simulations, len(pnls))) if keep_paths else None
|
||||
|
||||
for _ in range(n_simulations):
|
||||
for i in range(n_simulations):
|
||||
shuffled = rng.permutation(pnls)
|
||||
if sim_equities is not None:
|
||||
sim_equities[i] = initial_capital + np.cumsum(shuffled)
|
||||
sim = _path_metrics(shuffled, initial_capital)
|
||||
sim_sharpes.append(sim["sharpe"])
|
||||
if sim["sharpe"] >= actual["sharpe"]:
|
||||
@@ -75,7 +81,7 @@ def monte_carlo_test(
|
||||
dd_count += 1
|
||||
|
||||
sim_arr = np.array(sim_sharpes)
|
||||
return {
|
||||
result = {
|
||||
"actual_sharpe": round(actual["sharpe"], 4),
|
||||
"actual_max_dd": round(actual["max_dd"], 4),
|
||||
"p_value_sharpe": round(sharpe_count / n_simulations, 4),
|
||||
@@ -86,7 +92,25 @@ def monte_carlo_test(
|
||||
"simulated_sharpe_p95": round(float(np.percentile(sim_arr, 95)), 4),
|
||||
"n_simulations": n_simulations,
|
||||
"n_trades": len(trades),
|
||||
"sharpe_samples": [round(float(s), 4) for s in sim_sharpes],
|
||||
}
|
||||
if sim_equities is not None:
|
||||
idx = np.unique(np.linspace(0, len(pnls) - 1, min(len(pnls), 400)).astype(int))
|
||||
sample_rows = np.unique(
|
||||
np.linspace(0, n_simulations - 1, min(30, n_simulations)).astype(int)
|
||||
)
|
||||
result["equity_paths"] = {
|
||||
"steps": (idx + 1).tolist(),
|
||||
"initial_capital": round(float(initial_capital), 2),
|
||||
"actual": np.round((initial_capital + np.cumsum(pnls))[idx], 2).tolist(),
|
||||
"band_p5": np.round(np.percentile(sim_equities[:, idx], 5, axis=0), 2).tolist(),
|
||||
"band_p25": np.round(np.percentile(sim_equities[:, idx], 25, axis=0), 2).tolist(),
|
||||
"band_p50": np.round(np.percentile(sim_equities[:, idx], 50, axis=0), 2).tolist(),
|
||||
"band_p75": np.round(np.percentile(sim_equities[:, idx], 75, axis=0), 2).tolist(),
|
||||
"band_p95": np.round(np.percentile(sim_equities[:, idx], 95, axis=0), 2).tolist(),
|
||||
"samples": np.round(sim_equities[np.ix_(sample_rows, idx)], 2).tolist(),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _path_metrics(pnls: np.ndarray, initial_capital: float) -> Dict[str, float]:
|
||||
@@ -154,7 +178,7 @@ def bootstrap_sharpe_ci(
|
||||
upper = float(np.percentile(arr, (1 - alpha) * 100))
|
||||
prob_pos = float(np.mean(arr > 0))
|
||||
|
||||
return {
|
||||
result = {
|
||||
"observed_sharpe": round(observed, 4),
|
||||
"ci_lower": round(lower, 4),
|
||||
"ci_upper": round(upper, 4),
|
||||
@@ -163,6 +187,9 @@ def bootstrap_sharpe_ci(
|
||||
"confidence": confidence,
|
||||
"n_bootstrap": n_bootstrap,
|
||||
}
|
||||
if n_bootstrap <= 20_000:
|
||||
result["sharpe_samples"] = [round(float(s), 4) for s in boot_sharpes]
|
||||
return result
|
||||
|
||||
|
||||
def _sharpe(returns: np.ndarray, bars_per_year: int = 252) -> float:
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="hsl(27 90% 52%)"/>
|
||||
<!-- V -->
|
||||
<path d="M6 8 L11 24 L16 8" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- T -->
|
||||
<line x1="18" y1="8" x2="28" y2="8" stroke="rgba(255,255,255,0.75)" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="23" y1="8" x2="23" y2="24" stroke="rgba(255,255,255,0.75)" stroke-width="3" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="32" x2="32" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#EA580C"/>
|
||||
<stop offset="1" stop-color="#F7A316"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7.5" fill="url(#g)"/>
|
||||
<line x1="9.5" y1="14" x2="9.5" y2="24.5" stroke="#ffffff" stroke-opacity="0.75" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<rect x="7.5" y="16" width="4" height="6" rx="1" fill="#ffffff"/>
|
||||
<line x1="16" y1="9.5" x2="16" y2="21.5" stroke="#ffffff" stroke-opacity="0.75" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<rect x="14" y="11.5" width="4" height="7" rx="1" fill="#ffffff"/>
|
||||
<line x1="22.5" y1="5.5" x2="22.5" y2="18" stroke="#ffffff" stroke-opacity="0.75" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<rect x="20.5" y="7.5" width="4" height="7.5" rx="1" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 519 B After Width: | Height: | Size: 949 B |
+16
-23
@@ -1,25 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 48" fill="none">
|
||||
<!-- P mark: stylized from grid/network nodes -->
|
||||
<g>
|
||||
<!-- Vertical stem of P -->
|
||||
<rect x="4" y="4" width="6" height="40" rx="3" fill="#1a365d"/>
|
||||
<!-- P bowl: arc of connected nodes -->
|
||||
<circle cx="14" cy="10" r="2.5" fill="#0891b2"/>
|
||||
<circle cx="24" cy="6" r="2.5" fill="#0891b2"/>
|
||||
<circle cx="34" cy="8" r="2.5" fill="#0891b2"/>
|
||||
<circle cx="38" cy="16" r="2.5" fill="#0891b2"/>
|
||||
<circle cx="34" cy="24" r="2.5" fill="#0891b2"/>
|
||||
<circle cx="24" cy="26" r="2.5" fill="#0891b2"/>
|
||||
<circle cx="14" cy="24" r="2.5" fill="#0891b2"/>
|
||||
<!-- Node connections forming the P bowl -->
|
||||
<path d="M14 10 L24 6 L34 8 L38 16 L34 24 L24 26 L14 24" stroke="#0891b2" stroke-width="1.5" fill="none" stroke-linejoin="round"/>
|
||||
<!-- Internal data grid lines -->
|
||||
<line x1="24" y1="6" x2="24" y2="26" stroke="#22d3ee" stroke-width="0.8" opacity="0.5"/>
|
||||
<line x1="14" y1="16" x2="38" y2="16" stroke="#22d3ee" stroke-width="0.8" opacity="0.5"/>
|
||||
<!-- Corner accent node -->
|
||||
<circle cx="24" cy="16" r="1.8" fill="#22d3ee"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 48" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="4" y1="44" x2="44" y2="4" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#EA580C"/>
|
||||
<stop offset="1" stop-color="#F7A316"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(4 4) scale(1.25)">
|
||||
<rect width="32" height="32" rx="7.5" fill="url(#g)"/>
|
||||
<line x1="9.5" y1="14" x2="9.5" y2="24.5" stroke="#ffffff" stroke-opacity="0.75" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<rect x="7.5" y="16" width="4" height="6" rx="1" fill="#ffffff"/>
|
||||
<line x1="16" y1="9.5" x2="16" y2="21.5" stroke="#ffffff" stroke-opacity="0.75" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<rect x="14" y="11.5" width="4" height="7" rx="1" fill="#ffffff"/>
|
||||
<line x1="22.5" y1="5.5" x2="22.5" y2="18" stroke="#ffffff" stroke-opacity="0.75" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<rect x="20.5" y="7.5" width="4" height="7.5" rx="1" fill="#ffffff"/>
|
||||
</g>
|
||||
<!-- Wordmark -->
|
||||
<text x="52" y="34" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="22" fill="#1a365d" letter-spacing="-0.4">FIN-</text>
|
||||
<text x="103" y="34" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="22" fill="#0891b2" letter-spacing="-0.4">ANYTHING</text>
|
||||
<text x="56" y="33" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="22" letter-spacing="-0.4" fill="#1E293B">Vibe-<tspan fill="#EA580C">Trading</tspan></text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getChartTheme } from "@/lib/chart-theme";
|
||||
import { echarts } from "@/lib/echarts";
|
||||
import { useThemeDark } from "@/lib/theme-store";
|
||||
|
||||
interface Props {
|
||||
/** Raw simulation samples (e.g. simulated Sharpe ratios). */
|
||||
samples: number[];
|
||||
/** Observed value highlighted with a vertical marker line. */
|
||||
markerValue: number;
|
||||
markerLabel: string;
|
||||
/** Optional shaded interval (e.g. P5–P95 or a bootstrap CI). */
|
||||
bandFrom?: number;
|
||||
bandTo?: number;
|
||||
bandLabel?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** Histogram of a simulation distribution with an observed-value marker. */
|
||||
export function DistributionChart({
|
||||
samples,
|
||||
markerValue,
|
||||
markerLabel,
|
||||
bandFrom,
|
||||
bandTo,
|
||||
bandLabel,
|
||||
height = 200,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dark = useThemeDark();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || samples.length === 0) return;
|
||||
const t = getChartTheme();
|
||||
const chart = echarts.init(ref.current);
|
||||
|
||||
let lo = Math.min(markerValue, ...samples);
|
||||
let hi = Math.max(markerValue, ...samples);
|
||||
if (lo === hi) { lo -= 0.5; hi += 0.5; }
|
||||
const binCount = Math.min(60, Math.max(20, Math.round(Math.sqrt(samples.length))));
|
||||
const binWidth = (hi - lo) / binCount;
|
||||
const counts = new Array<number>(binCount).fill(0);
|
||||
for (const sample of samples) {
|
||||
const bin = Math.min(binCount - 1, Math.max(0, Math.floor((sample - lo) / binWidth)));
|
||||
counts[bin] += 1;
|
||||
}
|
||||
const labels = counts.map((_, i) => (lo + (i + 0.5) * binWidth).toFixed(2));
|
||||
const toIndex = (value: number) =>
|
||||
Math.max(0, Math.min(binCount - 1, (value - lo) / binWidth - 0.5));
|
||||
|
||||
chart.setOption({
|
||||
backgroundColor: "transparent",
|
||||
grid: { left: 8, right: 8, top: 28, bottom: 4, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: t.tooltipBg,
|
||||
borderColor: t.tooltipBorder,
|
||||
textStyle: { color: t.tooltipText, fontSize: 11 },
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: labels,
|
||||
axisLine: { lineStyle: { color: t.axisColor } },
|
||||
axisLabel: { color: t.textColor, fontSize: 10, interval: Math.max(1, Math.ceil(binCount / 8)) },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
splitLine: { lineStyle: { color: t.gridColor } },
|
||||
axisLabel: { color: t.textColor, fontSize: 10 },
|
||||
},
|
||||
series: [{
|
||||
type: "bar",
|
||||
data: counts,
|
||||
barCategoryGap: "12%",
|
||||
itemStyle: { color: t.infoColor + "99", borderRadius: [2, 2, 0, 0] },
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: "none",
|
||||
data: [{
|
||||
xAxis: toIndex(markerValue),
|
||||
label: { formatter: markerLabel, color: t.warningColor, fontSize: 10, position: "insideEndTop" },
|
||||
}],
|
||||
lineStyle: { color: t.warningColor, width: 2 },
|
||||
},
|
||||
...(bandFrom != null && bandTo != null ? {
|
||||
markArea: {
|
||||
silent: true,
|
||||
itemStyle: { color: t.infoColor + "14" },
|
||||
label: { color: t.textColor, fontSize: 10 },
|
||||
data: [[
|
||||
{ xAxis: toIndex(bandFrom), ...(bandLabel ? { name: bandLabel } : {}) },
|
||||
{ xAxis: toIndex(bandTo) },
|
||||
]],
|
||||
},
|
||||
} : {}),
|
||||
}],
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => chart.resize());
|
||||
ro.observe(ref.current!);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
chart.dispose();
|
||||
};
|
||||
}, [samples, markerValue, markerLabel, bandFrom, bandTo, bandLabel, dark]);
|
||||
|
||||
if (samples.length === 0) return null;
|
||||
return <div ref={ref} style={{ height }} />;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import type { MonteCarloEquityPaths } from "@/lib/api";
|
||||
import { getChartTheme } from "@/lib/chart-theme";
|
||||
import { abbreviateNum } from "@/lib/formatters";
|
||||
import { echarts } from "@/lib/echarts";
|
||||
import { useThemeDark } from "@/lib/theme-store";
|
||||
|
||||
interface Props {
|
||||
paths: MonteCarloEquityPaths;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monte Carlo fan chart: sampled shuffled-order equity paths (thin), the
|
||||
* P5–P95 / P25–P75 percentile envelope (shaded), the median (dashed), and
|
||||
* the actual path (bold) — all over trade sequence number, since the
|
||||
* simulation permutes trade order rather than calendar time.
|
||||
*/
|
||||
export function MonteCarloPathsChart({ paths, height = 260 }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dark = useThemeDark();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || paths.steps.length === 0) return;
|
||||
const t = getChartTheme();
|
||||
const chart = echarts.init(ref.current);
|
||||
|
||||
const actualLabel = i18n.t("validation.actualLabel");
|
||||
const medianLabel = i18n.t("validation.medianLabel");
|
||||
const diff = (upper: number[], lower: number[]) => upper.map((v, i) => v - lower[i]);
|
||||
|
||||
const bandSeries = (
|
||||
stackId: string,
|
||||
lower: number[],
|
||||
upper: number[],
|
||||
name: string,
|
||||
opacity: string,
|
||||
) => ([
|
||||
{
|
||||
name: `${stackId}-base`,
|
||||
type: "line",
|
||||
stack: stackId,
|
||||
data: lower,
|
||||
symbol: "none",
|
||||
silent: true,
|
||||
lineStyle: { opacity: 0 },
|
||||
tooltip: { show: false },
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
{
|
||||
name,
|
||||
type: "line",
|
||||
stack: stackId,
|
||||
data: diff(upper, lower),
|
||||
symbol: "none",
|
||||
silent: true,
|
||||
lineStyle: { opacity: 0 },
|
||||
areaStyle: { color: t.infoColor + opacity },
|
||||
tooltip: { show: false },
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
]);
|
||||
|
||||
const sampleSeries = paths.samples.map((row, index) => ({
|
||||
name: `sim-${index}`,
|
||||
type: "line",
|
||||
data: row,
|
||||
symbol: "none",
|
||||
silent: true,
|
||||
animation: false,
|
||||
lineStyle: { color: t.textColor, opacity: 0.1, width: 1 },
|
||||
tooltip: { show: false },
|
||||
emphasis: { disabled: true },
|
||||
}));
|
||||
|
||||
chart.setOption({
|
||||
backgroundColor: "transparent",
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: t.tooltipBg,
|
||||
borderColor: t.tooltipBorder,
|
||||
textStyle: { color: t.tooltipText, fontSize: 11 },
|
||||
},
|
||||
legend: {
|
||||
data: [actualLabel, medianLabel, "P25–P75", "P5–P95"],
|
||||
textStyle: { color: t.textColor, fontSize: 11 },
|
||||
top: 0,
|
||||
},
|
||||
grid: { left: 8, right: 8, top: 30, bottom: 22, containLabel: true },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: paths.steps,
|
||||
name: i18n.t("validation.tradeNumber"),
|
||||
nameLocation: "middle",
|
||||
nameGap: 22,
|
||||
nameTextStyle: { color: t.textColor, fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: t.axisColor } },
|
||||
axisLabel: { color: t.textColor, fontSize: 10 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
scale: true,
|
||||
splitLine: { lineStyle: { color: t.gridColor } },
|
||||
axisLabel: { color: t.textColor, fontSize: 10, formatter: (v: number) => abbreviateNum(v) },
|
||||
},
|
||||
series: [
|
||||
...bandSeries("outer", paths.band_p5, paths.band_p95, "P5–P95", "1f"),
|
||||
...bandSeries("inner", paths.band_p25, paths.band_p75, "P25–P75", "33"),
|
||||
...sampleSeries,
|
||||
{
|
||||
name: medianLabel,
|
||||
type: "line",
|
||||
data: paths.band_p50,
|
||||
symbol: "none",
|
||||
lineStyle: { color: t.textColor, width: 1, type: "dashed" },
|
||||
},
|
||||
{
|
||||
name: actualLabel,
|
||||
type: "line",
|
||||
data: paths.actual,
|
||||
symbol: "none",
|
||||
z: 10,
|
||||
lineStyle: { color: t.warningColor, width: 2.5 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => chart.resize());
|
||||
ro.observe(ref.current!);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
chart.dispose();
|
||||
};
|
||||
}, [paths, dark]);
|
||||
|
||||
if (paths.steps.length === 0) return null;
|
||||
return <div ref={ref} style={{ height }} />;
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import i18n from '@/i18n';
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ValidationData } from "@/lib/api";
|
||||
import { DistributionChart } from "@/components/charts/DistributionChart";
|
||||
import { MonteCarloPathsChart } from "@/components/charts/MonteCarloPathsChart";
|
||||
import { WalkForwardChart } from "@/components/charts/WalkForwardChart";
|
||||
|
||||
interface Props {
|
||||
data: ValidationData;
|
||||
/** Skip page padding and per-section card chrome when hosted inside another card. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function Badge({ value, good }: { value: string; good: boolean | null }) {
|
||||
@@ -53,18 +58,40 @@ function MonteCarloSection({ mc }: { mc: NonNullable<ValidationData["monte_carlo
|
||||
<Stat label={i18n.t("validation.simulatedMean")} value={mc.simulated_sharpe_mean.toFixed(2)} sub={`std ${mc.simulated_sharpe_std.toFixed(2)}`} />
|
||||
<Stat label={i18n.t("validation.simulatedRange")} value={`[${mc.simulated_sharpe_p5.toFixed(2)}, ${mc.simulated_sharpe_p95.toFixed(2)}]`} />
|
||||
</div>
|
||||
{/* Visual: where actual falls in distribution */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
|
||||
<span>P5: {mc.simulated_sharpe_p5.toFixed(2)}</span>
|
||||
<span>Actual: {mc.actual_sharpe.toFixed(2)}</span>
|
||||
<span>P95: {mc.simulated_sharpe_p95.toFixed(2)}</span>
|
||||
{/* Fan chart: full simulated path envelope over trade order */}
|
||||
{mc.equity_paths && mc.equity_paths.steps.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">{i18n.t("validation.pathsTitle")}</p>
|
||||
<MonteCarloPathsChart paths={mc.equity_paths} height={260} />
|
||||
</div>
|
||||
<div className="relative h-3 rounded-full bg-muted overflow-hidden">
|
||||
<div className="absolute inset-y-0 bg-zinc-300 dark:bg-zinc-600 rounded-full" style={barStyle(mc.simulated_sharpe_p5, mc.simulated_sharpe_p95, mc.simulated_sharpe_p5, mc.simulated_sharpe_p95)} />
|
||||
<div className="absolute top-0 bottom-0 w-0.5 bg-emerald-500" style={markerStyle(mc.actual_sharpe, mc.simulated_sharpe_p5, mc.simulated_sharpe_p95)} />
|
||||
)}
|
||||
{/* Distribution: every simulated Sharpe, or the compact fallback bar
|
||||
for runs validated before distributions were persisted. */}
|
||||
{mc.sharpe_samples && mc.sharpe_samples.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">{i18n.t("validation.sharpeDist")}</p>
|
||||
<DistributionChart
|
||||
samples={mc.sharpe_samples}
|
||||
markerValue={mc.actual_sharpe}
|
||||
markerLabel={`${i18n.t("validation.actualLabel")} ${mc.actual_sharpe.toFixed(2)}`}
|
||||
bandFrom={mc.simulated_sharpe_p5}
|
||||
bandTo={mc.simulated_sharpe_p95}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
|
||||
<span>P5: {mc.simulated_sharpe_p5.toFixed(2)}</span>
|
||||
<span>Actual: {mc.actual_sharpe.toFixed(2)}</span>
|
||||
<span>P95: {mc.simulated_sharpe_p95.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="relative h-3 rounded-full bg-muted overflow-hidden">
|
||||
<div className="absolute inset-y-0 bg-zinc-300 dark:bg-zinc-600 rounded-full" style={barStyle(mc.simulated_sharpe_p5, mc.simulated_sharpe_p95, mc.simulated_sharpe_p5, mc.simulated_sharpe_p95)} />
|
||||
<div className="absolute top-0 bottom-0 w-0.5 bg-emerald-500" style={markerStyle(mc.actual_sharpe, mc.simulated_sharpe_p5, mc.simulated_sharpe_p95)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,17 +114,32 @@ function BootstrapSection({ bs }: { bs: NonNullable<ValidationData["bootstrap"]>
|
||||
<Stat label={i18n.t("validation.medianSharpe")} value={bs.median_sharpe.toFixed(2)} />
|
||||
<Stat label={i18n.t("validation.probSharpePositive")} value={pctFmt(bs.prob_positive)} />
|
||||
</div>
|
||||
{/* CI bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
|
||||
<span>{bs.ci_lower.toFixed(2)}</span>
|
||||
<span>{bs.ci_upper.toFixed(2)}</span>
|
||||
{/* Distribution of bootstrap Sharpes, or the compact CI bar fallback */}
|
||||
{bs.sharpe_samples && bs.sharpe_samples.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">{i18n.t("validation.sharpeDist")}</p>
|
||||
<DistributionChart
|
||||
samples={bs.sharpe_samples}
|
||||
markerValue={bs.observed_sharpe}
|
||||
markerLabel={`${i18n.t("validation.actualLabel")} ${bs.observed_sharpe.toFixed(2)}`}
|
||||
bandFrom={bs.ci_lower}
|
||||
bandTo={bs.ci_upper}
|
||||
bandLabel={i18n.t("validation.ci", { pct: (bs.confidence * 100).toFixed(0) + "%" })}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative h-3 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("absolute inset-y-0 rounded-full", reliable ? "bg-emerald-500/30" : "bg-amber-500/30")} style={barStyle(bs.ci_lower, bs.ci_upper, Math.min(bs.ci_lower, 0), Math.max(bs.ci_upper, 1))} />
|
||||
<div className="absolute top-0 bottom-0 w-0.5 bg-foreground" style={markerStyle(bs.observed_sharpe, Math.min(bs.ci_lower, 0), Math.max(bs.ci_upper, 1))} />
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
|
||||
<span>{bs.ci_lower.toFixed(2)}</span>
|
||||
<span>{bs.ci_upper.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="relative h-3 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("absolute inset-y-0 rounded-full", reliable ? "bg-emerald-500/30" : "bg-amber-500/30")} style={barStyle(bs.ci_lower, bs.ci_upper, Math.min(bs.ci_lower, 0), Math.max(bs.ci_upper, 1))} />
|
||||
<div className="absolute top-0 bottom-0 w-0.5 bg-foreground" style={markerStyle(bs.observed_sharpe, Math.min(bs.ci_lower, 0), Math.max(bs.ci_upper, 1))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +162,15 @@ function WalkForwardSection({ wf }: { wf: NonNullable<ValidationData["walk_forwa
|
||||
<Stat label={i18n.t("validation.avgSharpe")} value={wf.sharpe_mean.toFixed(2)} sub={`std ${wf.sharpe_std.toFixed(2)}`} />
|
||||
<Stat label={i18n.t("validation.windows")} value={String(wf.n_windows)} />
|
||||
</div>
|
||||
{/* Per-window OOS returns over time */}
|
||||
{wf.windows.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">{i18n.t("validation.windowReturns")}</p>
|
||||
<WalkForwardChart windows={wf.windows} height={200} />
|
||||
</div>
|
||||
)}
|
||||
{/* Per-window table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
@@ -147,6 +197,7 @@ function WalkForwardSection({ wf }: { wf: NonNullable<ValidationData["walk_forwa
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +216,7 @@ function markerStyle(value: number, min: number, max: number) {
|
||||
return { left: `${Math.max(0, Math.min(100, left))}%` };
|
||||
}
|
||||
|
||||
export function ValidationPanel({ data }: Props) {
|
||||
export function ValidationPanel({ data, compact = false }: Props) {
|
||||
const hasMC = !!data.monte_carlo;
|
||||
const hasBS = !!data.bootstrap;
|
||||
const hasWF = !!data.walk_forward;
|
||||
@@ -174,11 +225,12 @@ export function ValidationPanel({ data }: Props) {
|
||||
return <p className="p-8 text-sm text-muted-foreground">{i18n.t("validation.noData")}</p>;
|
||||
}
|
||||
|
||||
const sectionClass = compact ? undefined : "rounded-xl border border-border/60 bg-card p-4 shadow-sm";
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
{hasMC && <MonteCarloSection mc={data.monte_carlo!} />}
|
||||
{hasBS && <BootstrapSection bs={data.bootstrap!} />}
|
||||
{hasWF && <WalkForwardSection wf={data.walk_forward!} />}
|
||||
<div className={compact ? "space-y-6" : "p-4 space-y-4"}>
|
||||
{hasMC && <section className={sectionClass}><MonteCarloSection mc={data.monte_carlo!} /></section>}
|
||||
{hasBS && <section className={sectionClass}><BootstrapSection bs={data.bootstrap!} /></section>}
|
||||
{hasWF && <section className={sectionClass}><WalkForwardSection wf={data.walk_forward!} /></section>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import type { ValidationData } from "@/lib/api";
|
||||
import { getChartTheme } from "@/lib/chart-theme";
|
||||
import { echarts } from "@/lib/echarts";
|
||||
import { useThemeDark } from "@/lib/theme-store";
|
||||
|
||||
type WalkForwardWindow = NonNullable<ValidationData["walk_forward"]>["windows"][number];
|
||||
|
||||
interface Props {
|
||||
windows: WalkForwardWindow[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** Per-window OOS returns as sign-colored bars with the Sharpe line overlaid. */
|
||||
export function WalkForwardChart({ windows, height = 200 }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dark = useThemeDark();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || windows.length === 0) return;
|
||||
const t = getChartTheme();
|
||||
const chart = echarts.init(ref.current);
|
||||
|
||||
const returnName = i18n.t("validation.return");
|
||||
const sharpeName = i18n.t("reports.sharpe");
|
||||
|
||||
chart.setOption({
|
||||
backgroundColor: "transparent",
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: t.tooltipBg,
|
||||
borderColor: t.tooltipBorder,
|
||||
textStyle: { color: t.tooltipText, fontSize: 11 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter: (params: any) => {
|
||||
if (!Array.isArray(params) || !params.length) return "";
|
||||
const w = windows[params[0].dataIndex];
|
||||
let html = `<b>${w ? `${w.start} ~ ${w.end}` : params[0].axisValue}</b>`;
|
||||
for (const p of params) {
|
||||
const suffix = p.seriesName === returnName ? "%" : "";
|
||||
html += `<br/>${p.marker} ${p.seriesName}: <b>${Number(p.value).toFixed(2)}${suffix}</b>`;
|
||||
}
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: [returnName, sharpeName],
|
||||
textStyle: { color: t.textColor, fontSize: 11 },
|
||||
top: 0,
|
||||
},
|
||||
grid: { left: 8, right: 8, top: 30, bottom: 4, containLabel: true },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: windows.map((w) => `W${w.window}`),
|
||||
axisLine: { lineStyle: { color: t.axisColor } },
|
||||
axisLabel: { color: t.textColor, fontSize: 10 },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: "value",
|
||||
splitLine: { lineStyle: { color: t.gridColor } },
|
||||
axisLabel: { color: t.textColor, fontSize: 10, formatter: "{value}%" },
|
||||
},
|
||||
{
|
||||
type: "value",
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: t.textColor, fontSize: 10 },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: returnName,
|
||||
type: "bar",
|
||||
yAxisIndex: 0,
|
||||
data: windows.map((w) => Number((w.return * 100).toFixed(2))),
|
||||
barMaxWidth: 42,
|
||||
itemStyle: {
|
||||
borderRadius: [2, 2, 0, 0],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
color: (params: any) => (Number(params.value) >= 0 ? t.upColor : t.downColor),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: sharpeName,
|
||||
type: "line",
|
||||
yAxisIndex: 1,
|
||||
data: windows.map((w) => Number(w.sharpe.toFixed(2))),
|
||||
symbol: "circle",
|
||||
symbolSize: 5,
|
||||
lineStyle: { color: t.infoColor, width: 1.5 },
|
||||
itemStyle: { color: t.infoColor },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => chart.resize());
|
||||
ro.observe(ref.current!);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
chart.dispose();
|
||||
};
|
||||
}, [windows, dark]);
|
||||
|
||||
if (windows.length === 0) return null;
|
||||
return <div ref={ref} style={{ height }} />;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Activity, Ban, CheckCircle2, Loader2, OctagonX } from "lucide-react";
|
||||
import { Activity, Ban, CheckCircle2, Loader2, OctagonX, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ApiError,
|
||||
@@ -102,8 +102,10 @@ interface RuntimeContextValue {
|
||||
liveActive: boolean;
|
||||
liveIsHalted: boolean;
|
||||
halting: boolean;
|
||||
resuming: boolean;
|
||||
refreshLiveStatus: () => Promise<void>;
|
||||
haltLive: () => Promise<void>;
|
||||
resumeLive: () => Promise<void>;
|
||||
}
|
||||
|
||||
const RuntimeContext = createContext<RuntimeContextValue | null>(null);
|
||||
@@ -120,6 +122,7 @@ export const LiveRuntimePanel = memo(forwardRef<LiveRuntimePanelHandle, Props>(
|
||||
const [hasCommittedMandate, setHasCommittedMandate] = useState(false);
|
||||
const [liveHalted, setLiveHalted] = useState<LiveHalted | null>(null);
|
||||
const [halting, setHalting] = useState(false);
|
||||
const [resuming, setResuming] = useState(false);
|
||||
const [liveStatusRefresh, setLiveStatusRefresh] = useState(0);
|
||||
const [liveStatus, setLiveStatus] = useState<LiveStatus | null>(null);
|
||||
const [liveStatusUnavailable, setLiveStatusUnavailable] = useState(false);
|
||||
@@ -199,6 +202,21 @@ export const LiveRuntimePanel = memo(forwardRef<LiveRuntimePanelHandle, Props>(
|
||||
}
|
||||
}, [halting, sessionId, t]);
|
||||
|
||||
const resumeLive = useCallback(async () => {
|
||||
if (resuming) return;
|
||||
setResuming(true);
|
||||
try {
|
||||
await api.resumeLive(sessionId ?? undefined);
|
||||
setLiveHalted(null);
|
||||
setLiveStatusRefresh((value) => value + 1);
|
||||
toast.success(t("agent.connectorResumed"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agent.failedToResumeConnector"));
|
||||
} finally {
|
||||
setResuming(false);
|
||||
}
|
||||
}, [resuming, sessionId, t]);
|
||||
|
||||
const liveStatusActive =
|
||||
liveStatus != null &&
|
||||
(liveStatus.global_halted ||
|
||||
@@ -219,8 +237,10 @@ export const LiveRuntimePanel = memo(forwardRef<LiveRuntimePanelHandle, Props>(
|
||||
liveActive,
|
||||
liveIsHalted,
|
||||
halting,
|
||||
resuming,
|
||||
refreshLiveStatus,
|
||||
haltLive,
|
||||
resumeLive,
|
||||
}), [
|
||||
haltLive,
|
||||
halting,
|
||||
@@ -229,6 +249,8 @@ export const LiveRuntimePanel = memo(forwardRef<LiveRuntimePanelHandle, Props>(
|
||||
liveStatus,
|
||||
liveStatusUnavailable,
|
||||
refreshLiveStatus,
|
||||
resumeLive,
|
||||
resuming,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -259,10 +281,24 @@ export function LiveRuntimeControl() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-t border-border/60 pt-2">
|
||||
{runtime.liveIsHalted ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg bg-destructive/10 px-2.5 py-1 text-xs font-medium text-destructive">
|
||||
<OctagonX className="h-3 w-3" />
|
||||
{t("agent.connectorHalted")}
|
||||
</span>
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg bg-destructive/10 px-2.5 py-1 text-xs font-medium text-destructive">
|
||||
<OctagonX className="h-3 w-3" />
|
||||
{t("agent.connectorHalted")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runtime.resumeLive}
|
||||
disabled={runtime.resuming}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
|
||||
title={t("agent.resumeConnectorTitle")}
|
||||
>
|
||||
{runtime.resuming
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <RotateCcw className="h-3 w-3" />}
|
||||
{t("agent.resumeConnector")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -39,25 +39,25 @@ function formatUsd(value: number | undefined): string {
|
||||
}
|
||||
|
||||
function formatRelative(value: string | number | null | undefined): string {
|
||||
if (value == null || value === "") return "never";
|
||||
if (value == null || value === "") return i18n.t("runnerStatus.never");
|
||||
const then = typeof value === "number"
|
||||
? (value < 1_000_000_000_000 ? value * 1000 : value)
|
||||
: new Date(value).getTime();
|
||||
if (!Number.isFinite(then)) return "unknown";
|
||||
if (!Number.isFinite(then)) return i18n.t("runnerStatus.unknown");
|
||||
const deltaSec = Math.round((Date.now() - then) / 1000);
|
||||
if (deltaSec < 0) return "just now";
|
||||
if (deltaSec < 60) return `${deltaSec}s ago`;
|
||||
if (deltaSec < 3600) return `${Math.floor(deltaSec / 60)}m ago`;
|
||||
if (deltaSec < 86_400) return `${Math.floor(deltaSec / 3600)}h ago`;
|
||||
return `${Math.floor(deltaSec / 86_400)}d ago`;
|
||||
if (deltaSec < 0) return i18n.t("runnerStatus.justNow");
|
||||
if (deltaSec < 60) return i18n.t("runnerStatus.secondsAgo", { n: deltaSec });
|
||||
if (deltaSec < 3600) return i18n.t("runnerStatus.minutesAgo", { n: Math.floor(deltaSec / 60) });
|
||||
if (deltaSec < 86_400) return i18n.t("runnerStatus.hoursAgo", { n: Math.floor(deltaSec / 3600) });
|
||||
return i18n.t("runnerStatus.daysAgo", { n: Math.floor(deltaSec / 86_400) });
|
||||
}
|
||||
|
||||
function formatCountdown(iso: string | undefined): { label: string; expired: boolean; soon: boolean } {
|
||||
if (!iso) return { label: "—", expired: false, soon: false };
|
||||
const target = new Date(iso).getTime();
|
||||
if (!Number.isFinite(target)) return { label: "unknown", expired: false, soon: false };
|
||||
if (!Number.isFinite(target)) return { label: i18n.t("runnerStatus.unknown"), expired: false, soon: false };
|
||||
const deltaSec = Math.round((target - Date.now()) / 1000);
|
||||
if (deltaSec <= 0) return { label: "expired", expired: true, soon: false };
|
||||
if (deltaSec <= 0) return { label: i18n.t("runnerStatus.expired"), expired: true, soon: false };
|
||||
const days = Math.floor(deltaSec / 86_400);
|
||||
const hours = Math.floor((deltaSec % 86_400) / 3600);
|
||||
const minutes = Math.floor((deltaSec % 3600) / 60);
|
||||
@@ -77,7 +77,7 @@ function summarizeLimits(limits: LiveMandateLimits | undefined): string {
|
||||
}
|
||||
|
||||
function fallbackAuthorizeInstruction(): string {
|
||||
return "Run `vibe-trading connector list`, choose the broker profile, then run `vibe-trading connector authorize <profile>` from the desktop session that will hold the broker connection.";
|
||||
return i18n.t("runnerStatus.fallbackInstruction");
|
||||
}
|
||||
|
||||
function BrokerRow({
|
||||
@@ -98,8 +98,8 @@ function BrokerRow({
|
||||
const mandate = broker.mandate ?? null;
|
||||
const countdown = formatCountdown(mandate?.expires_at);
|
||||
const authorizeInstruction = authorizeHint?.instruction
|
||||
?? (authorizeFailed ? fallbackAuthorizeInstruction() : "Loading connector authorization instructions...");
|
||||
const authorizeNote = "The connector channel stays read-only until OAuth succeeds and a mandate is committed.";
|
||||
?? (authorizeFailed ? fallbackAuthorizeInstruction() : i18n.t("runnerStatus.loadingInstructions"));
|
||||
const authorizeNote = i18n.t("runnerStatus.authorizeNote");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -124,14 +124,14 @@ function BrokerRow({
|
||||
try {
|
||||
if (runnerAlive) {
|
||||
await api.stopLiveRunner(brokerKey);
|
||||
toast.success(`Runner stopped for ${brokerKey}`);
|
||||
toast.success(i18n.t("runnerStatus.runnerStoppedFor", { broker: brokerKey }));
|
||||
} else {
|
||||
await api.startLiveRunner(brokerKey);
|
||||
toast.success(`Runner started for ${brokerKey}`);
|
||||
toast.success(i18n.t("runnerStatus.runnerStartedFor", { broker: brokerKey }));
|
||||
}
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Runner control failed.");
|
||||
toast.error(error instanceof Error ? error.message : i18n.t("runnerStatus.runnerControlFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -145,12 +145,12 @@ function BrokerRow({
|
||||
{authorized ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<ShieldCheck className="h-2.5 w-2.5" />
|
||||
Authorized
|
||||
{i18n.t("runnerStatus.authorized")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<CircleSlash className="h-2.5 w-2.5" />
|
||||
Not connected
|
||||
{i18n.t("runnerStatus.notConnected")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -163,7 +163,7 @@ function BrokerRow({
|
||||
<div className="grid gap-1.5 rounded-md border border-dashed border-primary/30 bg-primary/5 p-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-primary">
|
||||
<PlugZap className="h-3 w-3 shrink-0" />
|
||||
Connect this profile to enable connector runtime
|
||||
{i18n.t("runnerStatus.connectProfile")}
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-muted-foreground">
|
||||
{authorizeInstruction}
|
||||
@@ -180,16 +180,16 @@ function BrokerRow({
|
||||
<div className="rounded-md border bg-background/60 p-2">
|
||||
<div className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<CircleDot className={["h-2.5 w-2.5", runnerAlive ? "text-emerald-500" : "text-muted-foreground"].join(" ")} />
|
||||
Runner
|
||||
{i18n.t("runnerStatus.runner")}
|
||||
</div>
|
||||
<div className={["mt-0.5 text-xs font-semibold", runnerAlive ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground"].join(" ")}>
|
||||
{runnerAlive ? "Running" : "Stopped"}
|
||||
{runnerAlive ? i18n.t("runnerStatus.runnerRunning") : i18n.t("runnerStatus.runnerStopped")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background/60 p-2">
|
||||
<div className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Activity className="h-2.5 w-2.5" />
|
||||
Last tick
|
||||
{i18n.t("runnerStatus.lastTick")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs font-medium text-foreground">
|
||||
{formatRelative(broker.runner?.last_tick)}
|
||||
@@ -202,7 +202,7 @@ function BrokerRow({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<ShieldCheck className="h-2.5 w-2.5" />
|
||||
Active mandate
|
||||
{i18n.t("runnerStatus.activeMandate")}
|
||||
</div>
|
||||
{mandate.expires_at && (
|
||||
<span
|
||||
@@ -214,20 +214,20 @@ function BrokerRow({
|
||||
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
].join(" ")}
|
||||
title={`Expires ${new Date(mandate.expires_at).toLocaleString()}`}
|
||||
title={i18n.t("runnerStatus.expiresAt", { time: new Date(mandate.expires_at).toLocaleString() })}
|
||||
>
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
{countdown.expired ? "expired" : `expires in ${countdown.label}`}
|
||||
{countdown.expired ? i18n.t("runnerStatus.expired") : i18n.t("runnerStatus.expiresIn", { time: countdown.label })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-[11px] text-foreground">
|
||||
{summarizeLimits(mandate.limits) || "limits unavailable"}
|
||||
{summarizeLimits(mandate.limits) || i18n.t("runnerStatus.limitsUnavailable")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed bg-background/40 p-2 text-[10px] text-muted-foreground">
|
||||
No active mandate. Ask the agent to propose one, then commit it before starting the connector runtime.
|
||||
{i18n.t("runnerStatus.noActiveMandate")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -235,11 +235,11 @@ function BrokerRow({
|
||||
{halted ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-destructive">
|
||||
<OctagonX className="h-3 w-3" />
|
||||
Halted — runner controls disabled
|
||||
{i18n.t("runnerStatus.haltedControlsDisabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{runnerAlive ? "Runtime active inside mandate" : "Idle"}
|
||||
{runnerAlive ? i18n.t("runnerStatus.runtimeActive") : i18n.t("runnerStatus.idle")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -252,10 +252,10 @@ function BrokerRow({
|
||||
? "border-destructive/40 text-destructive hover:bg-destructive/10"
|
||||
: "border-primary/40 text-primary hover:bg-primary/10",
|
||||
].join(" ")}
|
||||
title={runnerAlive ? "Stop the persistent runner" : "Start the persistent runner"}
|
||||
title={runnerAlive ? i18n.t("runnerStatus.stopRunnerTitle") : i18n.t("runnerStatus.startRunnerTitle")}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Power className="h-3 w-3" />}
|
||||
{runnerAlive ? "Stop runner" : "Start runner"}
|
||||
{runnerAlive ? i18n.t("runnerStatus.stopRunner") : i18n.t("runnerStatus.startRunner")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -36,11 +36,47 @@ function calculateEta(tc: ToolCallEntry, previous?: EtaSample): number | null {
|
||||
}
|
||||
|
||||
function formatStepElapsed(seconds: number): string {
|
||||
const wholeSeconds = Math.max(0, Math.floor(seconds));
|
||||
if (seconds < 0.05) return "<0.1s";
|
||||
if (seconds < 1) return `${seconds.toFixed(1)}s`;
|
||||
const wholeSeconds = Math.floor(seconds);
|
||||
if (wholeSeconds < 60) return `${wholeSeconds}s`;
|
||||
return `${Math.floor(wholeSeconds / 60)}m ${wholeSeconds % 60}s`;
|
||||
}
|
||||
|
||||
/* ---------- Argument summary ----------
|
||||
* The step label alone ("Browse the factor zoo") is identical for every call
|
||||
* of the same tool; the argument summary is what tells seven such steps
|
||||
* apart. Prefer the arguments users phrase requests in (names, symbols,
|
||||
* queries) over incidental ones.
|
||||
*/
|
||||
const DETAIL_ARG_KEYS = [
|
||||
"name", "skill", "skill_name", "factor", "factor_id", "alpha_id",
|
||||
"symbol", "symbols", "code", "query", "q", "keyword", "action",
|
||||
"path", "file", "filename", "url", "source", "id",
|
||||
];
|
||||
|
||||
function truncateDetail(value: string, max = 42): string {
|
||||
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
||||
}
|
||||
|
||||
function detailFor(entry: ToolCallEntry): string {
|
||||
const args = entry.arguments ?? {};
|
||||
for (const key of DETAIL_ARG_KEYS) {
|
||||
const value = args[key];
|
||||
if (typeof value === "string" && value.trim()) return truncateDetail(value.trim());
|
||||
}
|
||||
const fallback = Object.values(args).find(
|
||||
(value) => typeof value === "string" && value.trim() && value.trim().length <= 60,
|
||||
);
|
||||
return fallback ? truncateDetail(fallback.trim()) : "";
|
||||
}
|
||||
|
||||
function entryElapsedSeconds(entry: ToolCallEntry): number | undefined {
|
||||
if (entry.elapsed_s != null) return entry.elapsed_s;
|
||||
if (entry.elapsed_ms != null) return entry.elapsed_ms / 1000;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/* ---------- Determinate progress ring ---------- */
|
||||
interface RingProps {
|
||||
current: number;
|
||||
@@ -83,23 +119,25 @@ function ProgressRing({ current, total }: RingProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Single tool row ---------- */
|
||||
/* ---------- Single row: one tool call, or a run of successful same-tool calls ---------- */
|
||||
interface RowProps {
|
||||
entry: ToolCallEntry;
|
||||
stepIndex: number;
|
||||
connector?: "branch" | "end" | "none";
|
||||
entries: ToolCallEntry[];
|
||||
eta: number | null;
|
||||
}
|
||||
|
||||
function ToolRow({ entry, stepIndex, connector = "none", eta }: RowProps): JSX.Element {
|
||||
function ToolRow({ entries, eta }: RowProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const progress = entry.progress;
|
||||
const entry = entries[entries.length - 1];
|
||||
const progress = entries.length === 1 ? entry.progress : undefined;
|
||||
const hasDeterminate = !!(progress && typeof progress.current === "number" && typeof progress.total === "number" && progress.total > 0);
|
||||
const stage = progress?.stage || "";
|
||||
const message = progress?.message || "";
|
||||
const elapsedSeconds = entry.elapsed_s ?? (
|
||||
entry.elapsed_ms != null ? entry.elapsed_ms / 1000 : undefined
|
||||
);
|
||||
|
||||
let elapsedSeconds: number | undefined;
|
||||
for (const item of entries) {
|
||||
const value = entryElapsedSeconds(item);
|
||||
if (value != null) elapsedSeconds = (elapsedSeconds ?? 0) + value;
|
||||
}
|
||||
|
||||
const icon = entry.status === "error"
|
||||
? <XCircle className="h-3 w-3 text-danger shrink-0" />
|
||||
@@ -110,19 +148,23 @@ function ToolRow({ entry, stepIndex, connector = "none", eta }: RowProps): JSX.E
|
||||
: <Loader2 className="h-3 w-3 animate-spin text-primary shrink-0" />;
|
||||
|
||||
const localized = localizeToolName(entry.tool);
|
||||
const stepLabel = t("toolProgress.step" as never, { step: stepIndex, tool: localized });
|
||||
const details = [...new Set(entries.map(detailFor).filter(Boolean))];
|
||||
const detailText = details.slice(0, 3).join(", ") + (details.length > 3 ? ` +${details.length - 3}` : "");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-x-2 gap-y-0.5 text-xs min-w-0">
|
||||
{/* Primary row */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{connector !== "none" && (
|
||||
<span className="text-border/60 shrink-0 w-3 text-center" aria-hidden="true">
|
||||
{connector === "branch" ? "├" : "└"}
|
||||
</span>
|
||||
)}
|
||||
{icon}
|
||||
<span className="text-foreground truncate">{stepLabel}</span>
|
||||
<span className="text-foreground shrink-0">
|
||||
{localized}
|
||||
{entries.length > 1 && (
|
||||
<span className="text-muted-foreground"> ×{entries.length}</span>
|
||||
)}
|
||||
</span>
|
||||
{detailText && (
|
||||
<span className="min-w-0 truncate text-muted-foreground/80">{detailText}</span>
|
||||
)}
|
||||
{elapsedSeconds != null && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -174,16 +216,16 @@ export interface ToolProgressIndicatorProps {
|
||||
toolCalls: ToolCallEntry[];
|
||||
}
|
||||
|
||||
interface IndexedToolCall {
|
||||
entry: ToolCallEntry;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded activity step rows.
|
||||
*
|
||||
* ActivityLine owns status, disclosure and announcements; this component is
|
||||
* deliberately only the shared row renderer so the chat has one status surface.
|
||||
*
|
||||
* Rows read chronologically top-to-bottom (the running call is naturally the
|
||||
* newest, at the bottom); consecutive successful calls of the same tool
|
||||
* coalesce into one "×N" row so repeated lookups don't become a wall of
|
||||
* identical lines. Errors and the running call always keep their own row.
|
||||
*/
|
||||
export const ToolProgressIndicator = memo(function ToolProgressIndicator({
|
||||
toolCalls,
|
||||
@@ -191,24 +233,24 @@ export const ToolProgressIndicator = memo(function ToolProgressIndicator({
|
||||
const etaSamplesRef = useRef<Map<string, EtaSample>>(new Map());
|
||||
|
||||
const { rows, running } = useMemo(() => {
|
||||
const runningRows: IndexedToolCall[] = [];
|
||||
const terminalRows: IndexedToolCall[] = [];
|
||||
const rows: ToolCallEntry[][] = [];
|
||||
const running: ToolCallEntry[] = [];
|
||||
|
||||
toolCalls.forEach((entry, index) => {
|
||||
const indexed = { entry, index };
|
||||
if (entry.status === "running") {
|
||||
running.push(entry);
|
||||
runningRows.push(indexed);
|
||||
for (const entry of toolCalls) {
|
||||
if (entry.status === "running") running.push(entry);
|
||||
const lastGroup = rows[rows.length - 1];
|
||||
if (
|
||||
entry.status === "ok"
|
||||
&& lastGroup
|
||||
&& lastGroup[0].status === "ok"
|
||||
&& lastGroup[0].tool === entry.tool
|
||||
) {
|
||||
lastGroup.push(entry);
|
||||
} else {
|
||||
terminalRows.push(indexed);
|
||||
rows.push([entry]);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
running,
|
||||
rows: [...runningRows, ...terminalRows.reverse()],
|
||||
};
|
||||
}
|
||||
return { rows, running };
|
||||
}, [toolCalls]);
|
||||
|
||||
const etaById = useMemo(() => {
|
||||
@@ -246,14 +288,12 @@ export const ToolProgressIndicator = memo(function ToolProgressIndicator({
|
||||
if (toolCalls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-1">
|
||||
{rows.map(({ entry, index }, rowIndex) => (
|
||||
<div className="min-w-0 space-y-1 border-s border-border/40 ps-3">
|
||||
{rows.map((entries) => (
|
||||
<ToolRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
stepIndex={index + 1}
|
||||
connector={rowIndex === rows.length - 1 ? "end" : "branch"}
|
||||
eta={etaById.get(entry.id) ?? null}
|
||||
key={entries[0].id}
|
||||
entries={entries}
|
||||
eta={etaById.get(entries[entries.length - 1].id) ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -239,6 +239,7 @@ export function WelcomeScreen({ onExample }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [greetingKey] = useState(() => pickGreetingKey());
|
||||
const [isExamplesOpen, setIsExamplesOpen] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState(0);
|
||||
const examplesTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
@@ -310,33 +311,52 @@ export function WelcomeScreen({ onExample }: Props) {
|
||||
}`}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="grid grid-cols-1 gap-3 pt-8 sm:grid-cols-2">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<div key={cat.labelKey} className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 px-1 text-xs font-medium text-muted-foreground">
|
||||
{/* One category at a time: a chip tab bar + that category's cards.
|
||||
Rendering all 8 categories at once (grid or masonry) reads as
|
||||
an unstructured wall — categories have uneven card counts. */}
|
||||
<div className="pt-6">
|
||||
<div
|
||||
className="flex flex-wrap justify-center gap-1.5"
|
||||
role="tablist"
|
||||
aria-label={t("welcome.browseAllExamples" as any)}
|
||||
>
|
||||
{CATEGORIES.map((cat, index) => (
|
||||
<button
|
||||
key={cat.labelKey}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={index === activeCategory}
|
||||
tabIndex={isExamplesOpen ? 0 : -1}
|
||||
onClick={() => setActiveCategory(index)}
|
||||
className={
|
||||
index === activeCategory
|
||||
? "inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary"
|
||||
: "inline-flex items-center gap-1.5 rounded-full border border-transparent px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-border/60 hover:text-foreground"
|
||||
}
|
||||
>
|
||||
{cat.icon}
|
||||
<span>{t(cat.labelKey as any)}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{cat.examples.map((ex) => (
|
||||
<button
|
||||
key={ex.titleKey}
|
||||
type="button"
|
||||
tabIndex={isExamplesOpen ? 0 : -1}
|
||||
onClick={() => onExample(t(ex.promptKey as any))}
|
||||
className="block w-full rounded-xl border border-border/60 px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
>
|
||||
<span className="text-sm font-medium leading-snug text-foreground">
|
||||
{t(ex.titleKey as any)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-snug text-muted-foreground">
|
||||
{t(ex.descKey as any)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" className="mt-4 grid gap-2 text-left sm:grid-cols-2">
|
||||
{CATEGORIES[activeCategory].examples.map((ex) => (
|
||||
<button
|
||||
key={ex.titleKey}
|
||||
type="button"
|
||||
tabIndex={isExamplesOpen ? 0 : -1}
|
||||
onClick={() => onExample(t(ex.promptKey as any))}
|
||||
className="block w-full rounded-xl border border-border/60 px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
>
|
||||
<span className="text-sm font-medium leading-snug text-foreground">
|
||||
{t(ex.titleKey as any)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-snug text-muted-foreground">
|
||||
{t(ex.descKey as any)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("ToolProgressIndicator", () => {
|
||||
expect(screen.getByText(/Read file/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prioritizes running rows, then all terminal rows newest-first", () => {
|
||||
it("renders rows chronologically with the running call last", () => {
|
||||
const tcs = [
|
||||
makeTc({ id: "tc-1", tool: "bash", status: "ok" }),
|
||||
makeTc({ id: "tc-2", tool: "write_file", status: "error" }),
|
||||
@@ -98,15 +98,31 @@ describe("ToolProgressIndicator", () => {
|
||||
];
|
||||
render(<ToolProgressIndicator toolCalls={tcs} />);
|
||||
|
||||
const visibleSteps = screen.getAllByText(/^Step /).map((node) => node.textContent);
|
||||
expect(visibleSteps).toEqual([
|
||||
"Step 4 · Read file",
|
||||
"Step 3 · Run the backtest",
|
||||
"Step 2 · Generate code",
|
||||
"Step 1 · Run command",
|
||||
const labels = screen
|
||||
.getAllByText(/Run command|Generate code|Run the backtest|Read file/)
|
||||
.map((node) => node.textContent);
|
||||
expect(labels).toEqual([
|
||||
"Run command",
|
||||
"Generate code",
|
||||
"Run the backtest",
|
||||
"Read file",
|
||||
]);
|
||||
});
|
||||
|
||||
it("coalesces consecutive successful calls of the same tool into one ×N row", () => {
|
||||
const tcs = [
|
||||
makeTc({ id: "bt-1", status: "ok", arguments: { symbol: "AAPL" }, elapsed_ms: 300 }),
|
||||
makeTc({ id: "bt-2", status: "ok", arguments: { symbol: "MSFT" }, elapsed_ms: 400 }),
|
||||
makeTc({ id: "sh-1", tool: "bash", status: "running" }),
|
||||
];
|
||||
render(<ToolProgressIndicator toolCalls={tcs} />);
|
||||
|
||||
expect(screen.getAllByText(/Run the backtest/)).toHaveLength(1);
|
||||
expect(screen.getByText("×2")).toBeInTheDocument();
|
||||
expect(screen.getByText("AAPL, MSFT")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.7s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows determinate progress bar when progress data exists", () => {
|
||||
const tcs = [
|
||||
makeTc({
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("WelcomeScreen", () => {
|
||||
expect(onExample).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("reveals all eight categories and 21 examples from the disclosure", async () => {
|
||||
it("reveals eight category tabs and switches example cards from the disclosure", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WelcomeScreen onExample={onExample} />);
|
||||
|
||||
@@ -116,8 +116,9 @@ describe("WelcomeScreen", () => {
|
||||
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
expect(library).toHaveAttribute("aria-hidden", "false");
|
||||
expect(within(library!).getAllByRole("button")).toHaveLength(21);
|
||||
expect(library!.querySelectorAll('svg[aria-hidden="true"]')).toHaveLength(8);
|
||||
// One category at a time: 8 tab chips, only the active category's cards.
|
||||
expect(within(library!).getAllByRole("tab")).toHaveLength(8);
|
||||
expect(within(library!).getAllByRole("button")).toHaveLength(3);
|
||||
expect(within(library!).getAllByRole("button")[0]).toHaveClass(
|
||||
"focus-visible:ring-2",
|
||||
"focus-visible:ring-primary/40",
|
||||
@@ -134,6 +135,13 @@ describe("WelcomeScreen", () => {
|
||||
]) {
|
||||
expect(within(library!).getByText(category)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
await user.click(within(library!).getByRole("tab", { name: /Value Investing/ }));
|
||||
expect(within(library!).getByRole("tab", { name: /Value Investing/ })).toHaveAttribute("aria-selected", "true");
|
||||
expect(within(library!).getAllByRole("button")).toHaveLength(4);
|
||||
expect(
|
||||
within(library!).getByRole("button", { name: /Check if a stock is expensive/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the example library with Escape and restores focus to its trigger", async () => {
|
||||
|
||||
@@ -11,33 +11,20 @@ export function BrandMark({
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect width="32" height="32" rx="6" fill="hsl(27 90% 52%)" />
|
||||
<path
|
||||
d="M6 8 L11 24 L16 8"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1="18"
|
||||
y1="8"
|
||||
x2="28"
|
||||
y2="8"
|
||||
stroke="rgba(255,255,255,0.75)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="23"
|
||||
y1="8"
|
||||
x2="23"
|
||||
y2="24"
|
||||
stroke="rgba(255,255,255,0.75)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="vt-mark-grad" x1="0" y1="32" x2="32" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#EA580C" />
|
||||
<stop offset="1" stopColor="#F7A316" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7.5" fill="url(#vt-mark-grad)" />
|
||||
{/* Three ascending candlesticks. */}
|
||||
<line x1="9.5" y1="14" x2="9.5" y2="24.5" stroke="#ffffff" strokeOpacity="0.75" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<rect x="7.5" y="16" width="4" height="6" rx="1" fill="#ffffff" />
|
||||
<line x1="16" y1="9.5" x2="16" y2="21.5" stroke="#ffffff" strokeOpacity="0.75" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<rect x="14" y="11.5" width="4" height="7" rx="1" fill="#ffffff" />
|
||||
<line x1="22.5" y1="5.5" x2="22.5" y2="18" stroke="#ffffff" strokeOpacity="0.75" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<rect x="20.5" y="7.5" width="4" height="7.5" rx="1" fill="#ffffff" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,11 @@ interface Props {
|
||||
|
||||
export function ConnectionBanner({ status, retryAttempt }: Props) {
|
||||
const { t } = useTranslation();
|
||||
if (status === "connected") return null;
|
||||
const terminal = status === "disconnected" || (
|
||||
status === "reconnecting" && (retryAttempt ?? 0) >= 5
|
||||
);
|
||||
// "disconnected" is the store's boot state and the deliberate-teardown state
|
||||
// (navigating away from a session) — neither is a connection loss, so only
|
||||
// an active reconnect loop may surface the banner.
|
||||
if (status !== "reconnecting") return null;
|
||||
const terminal = (retryAttempt ?? 0) >= 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -26,7 +26,6 @@ export function Layout() {
|
||||
{ to: "/alpha-zoo", icon: Layers, label: t('layout.alphaZoo') },
|
||||
{ to: "/settings", icon: Settings, label: t('layout.settings') },
|
||||
{ to: "/correlation", icon: BarChart3, label: t('layout.correlation') },
|
||||
{ to: "/about", icon: BarChart3, label: t('layout.about') },
|
||||
];
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -203,9 +202,11 @@ export function Layout() {
|
||||
{streamingSessionId === s.session_id ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-primary" />
|
||||
) : (
|
||||
// Transparent placeholder keeps titles aligned with
|
||||
// spinner rows without a meaningless gray dot.
|
||||
<span className={cn(
|
||||
"h-1.5 w-1.5 rounded-full shrink-0",
|
||||
isActive ? "bg-primary/70" : "bg-muted-foreground/40"
|
||||
isActive ? "bg-primary/70" : "bg-transparent"
|
||||
)} />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{s.title || s.session_id.slice(0, 16)}</span>
|
||||
@@ -278,7 +279,15 @@ export function Layout() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 max-md:items-center">
|
||||
<LanguageSwitcher />
|
||||
<p className="text-[10px] text-muted-foreground/60 max-md:hidden">{t('app.version')}</p>
|
||||
{/* About is marketing, not a work surface — it lives here by
|
||||
the version stamp instead of in the primary nav. */}
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/60 max-md:hidden">
|
||||
<span>{t('app.version')}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Link to="/about" className="transition-colors hover:text-foreground">
|
||||
{t('layout.about')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,10 +18,9 @@ describe("ConnectionBanner", () => {
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders the disconnected terminal state with a reload affordance", () => {
|
||||
render(<ConnectionBanner status="disconnected" />);
|
||||
expect(screen.getByText(/Connection lost/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
|
||||
it("renders nothing for the idle disconnected state", () => {
|
||||
const { container } = render(<ConnectionBanner status="disconnected" />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("shows the calm reconnecting message regardless of attempt count", () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"layout": {
|
||||
"home": "الرئيسية",
|
||||
"agent": "الوكيل",
|
||||
"runtime": "وقت التشغيل",
|
||||
"runtime": "مراقبة التداول",
|
||||
"reports": "التقارير",
|
||||
"alphaZoo": "مكتبة ألفا",
|
||||
"settings": "الإعدادات",
|
||||
@@ -28,14 +28,14 @@
|
||||
"about": "حول"
|
||||
},
|
||||
"runtime": {
|
||||
"monitorBadge": "مراقب وقت التشغيل",
|
||||
"title": "حالة وقت التشغيل الحي / الورقي",
|
||||
"monitorBadge": "مراقبة التداول",
|
||||
"title": "حالة التشغيل الحي / الورقي",
|
||||
"subtitlePre": "حالة للقراءة فقط من",
|
||||
"subtitlePost": ". لا تُصرح هذه الصفحة للموصلات، ولا تُشغّل المُشغّلات أو تُوقفها، ولا تُرسل أوامر أو تلغيها، ولا تُغيّر التفويض.",
|
||||
"refresh": "تحديث",
|
||||
"unavailableTitle": "حالة وقت التشغيل غير متاحة",
|
||||
"unavailableTitle": "حالة التشغيل غير متاحة",
|
||||
"unavailableHint": "اعتبر وقت تشغيل الموصل غير متاح حتى يوفّر الخادم لقطة حالة حالية.",
|
||||
"statusUnavailable": "حالة وقت التشغيل غير متاحة.",
|
||||
"statusUnavailable": "حالة التشغيل غير متاحة.",
|
||||
"globalHalt": "إيقاف شامل",
|
||||
"halted": "موقوف",
|
||||
"clear": "مسح",
|
||||
@@ -43,7 +43,7 @@
|
||||
"authorized": "مُصادَق",
|
||||
"runners": "المُشغّلات",
|
||||
"running": "{{count}} قيد التشغيل",
|
||||
"noProfilesTitle": "لم يتم الإبلاغ عن أي ملفات تعريف لوقت التشغيل",
|
||||
"noProfilesTitle": "لم يتم الإبلاغ عن أي ملفات تعريف للموصلات",
|
||||
"noProfilesBody": "لم يُرجع الخادم أي ملفات تعريف وسيط حي أو ورقي معروفة.",
|
||||
"authPresent": "المصادقة موجودة",
|
||||
"authMissing": "المصادقة مفقودة",
|
||||
@@ -67,10 +67,10 @@
|
||||
"expired": "منتهي الصلاحية",
|
||||
"limits": "الحدود",
|
||||
"noMandate": "لم يتم الإبلاغ عن أي تفويض نشط.",
|
||||
"riskStateTitle": "حالة مخاطر وقت التشغيل",
|
||||
"riskStateTitle": "حالة مخاطر التشغيل",
|
||||
"riskHalted": "المخاطر موقوفة",
|
||||
"riskHaltedDesc": "تم تشغيل قاطع الخطر لملف التعريف هذا. اعتبر وقت تشغيل الموصل متوقفًا حتى تُمسح الحالة.",
|
||||
"riskActive": "وقت التشغيل نشط",
|
||||
"riskActive": "قيد التشغيل",
|
||||
"riskActiveDesc": "المُشغّل نشط ويوجد تفويض غير منتهي الصلاحية. تظل هذه الصفحة للقراءة فقط.",
|
||||
"riskIdle": "جاهز لكن خامل",
|
||||
"riskIdleDesc": "التفويض والصلاحية موجودان، لكن المُشغّل ليس نشطًا.",
|
||||
@@ -434,7 +434,7 @@
|
||||
"uploadFailed": "فشل الرفع: {{error}}",
|
||||
"failedToSend": "فشل إرسال الرسالة، يُرجى المحاولة مرة أخرى.",
|
||||
"failedToContinue": "فشل متابعة الهدف، يُرجى المحاولة مرة أخرى.",
|
||||
"runtimeBadge": "وقت التشغيل",
|
||||
"runtimeBadge": "مراقبة التداول",
|
||||
"activeResearchGoal": "هدف بحث نشط",
|
||||
"evidenceCollectedTitle": "الأدلة المجمّعة نحو هدف البحث هذا",
|
||||
"evidenceCount": "{{count}} دليل",
|
||||
@@ -470,7 +470,11 @@
|
||||
"steps": "{{count}} خطوات",
|
||||
"continue": "متابعة",
|
||||
"reattach": "إعادة الاتصال"
|
||||
}
|
||||
},
|
||||
"resumeConnector": "استئناف الموصل",
|
||||
"resumeConnectorTitle": "مسح مفتاح الإيقاف واستئناف نشاط الموصل",
|
||||
"connectorResumed": "تم استئناف الموصل",
|
||||
"failedToResumeConnector": "فشل استئناف الموصل."
|
||||
},
|
||||
"runDetail": {
|
||||
"title": "تفاصيل التشغيل",
|
||||
@@ -522,7 +526,21 @@
|
||||
"noCodeFiles": "لا توجد ملفات شفرة.",
|
||||
"fullReport": "التقرير الكامل →",
|
||||
"unknown": "غير معروف",
|
||||
"noneRecorded": "لا توجد سجلات"
|
||||
"noneRecorded": "لا توجد سجلات",
|
||||
"tradesCount": "{{count}} صفقة",
|
||||
"sideSummary": "شراء {{buy}} · بيع {{sell}}",
|
||||
"totalPnl": "إجمالي الربح/الخسارة",
|
||||
"pnl": "الربح/الخسارة",
|
||||
"returnPct": "العائد",
|
||||
"holdingDays": "أيام الاحتفاظ",
|
||||
"showMore": "عرض {{count}} أخرى",
|
||||
"allSymbols": "كل الرموز",
|
||||
"sideAll": "الكل",
|
||||
"sideBuy": "شراء",
|
||||
"sideSell": "بيع",
|
||||
"copyCode": "نسخ",
|
||||
"codeCopied": "تم نسخ الكود",
|
||||
"codeLines": "{{count}} سطر"
|
||||
},
|
||||
"compare": {
|
||||
"title": "مقارنة الاستراتيجيات",
|
||||
@@ -552,7 +570,11 @@
|
||||
"exposureTime": "وقت التعرّض",
|
||||
"avgHoldingPeriod": "متوسط فترة الاحتفاظ",
|
||||
"better": "أفضل",
|
||||
"worse": "أسوأ"
|
||||
"worse": "أسوأ",
|
||||
"equityOverlay": "مقارنة منحنى رأس المال",
|
||||
"rebasedMode": "معايرة = 100",
|
||||
"rawMode": "خام",
|
||||
"swapRuns": "تبديل"
|
||||
},
|
||||
"correlation": {
|
||||
"title": "مصفوفة الارتباط",
|
||||
@@ -741,7 +763,13 @@
|
||||
"period2": "الفترة",
|
||||
"return": "العائد",
|
||||
"maxDd": "أقصى DD",
|
||||
"winRate": "معدل الفوز"
|
||||
"winRate": "معدل الفوز",
|
||||
"pathsTitle": "مسارات رأس المال المحاكاة",
|
||||
"sharpeDist": "توزيع نسبة شارب",
|
||||
"actualLabel": "الفعلي",
|
||||
"medianLabel": "الوسيط",
|
||||
"windowReturns": "عائد كل نافذة",
|
||||
"tradeNumber": "رقم الصفقة"
|
||||
},
|
||||
"thinking": {
|
||||
"processing": "جارٍ المعالجة",
|
||||
@@ -770,14 +798,25 @@
|
||||
"limitsUnavailable": "الحدود غير متاحة",
|
||||
"noActiveMandate": "لا يوجد تفويض نشط. اطلب من الوكيل اقتراح واحد، ثم التزم به قبل بدء وقت تشغيل الموصل.",
|
||||
"haltedControlsDisabled": "موقوف — تم تعطيل تحكّمات المُشغّل",
|
||||
"runtimeActive": "وقت التشغيل نشط ضمن التفويض",
|
||||
"runtimeActive": "قيد التشغيل ضمن التفويض",
|
||||
"idle": "خامل",
|
||||
"stopRunner": "إيقاف المُشغّل",
|
||||
"startRunner": "تشغيل المُشغّل",
|
||||
"never": "أبدًا",
|
||||
"unknown": "غير معروف",
|
||||
"justNow": "الآن",
|
||||
"loadingInstructions": "جارٍ تحميل تعليمات تفويض الموصل..."
|
||||
"loadingInstructions": "جارٍ تحميل تعليمات تفويض الموصل...",
|
||||
"fallbackInstruction": "شغّل `vibe-trading connector list` لاختيار ملف تعريف الوسيط، ثم شغّل `vibe-trading connector authorize <الملف>` من جلسة سطح المكتب التي ستحتفظ باتصال الوسيط.",
|
||||
"secondsAgo": "قبل {{n}} ث",
|
||||
"minutesAgo": "قبل {{n}} د",
|
||||
"hoursAgo": "قبل {{n}} س",
|
||||
"daysAgo": "قبل {{n}} يوم",
|
||||
"runnerStartedFor": "تم تشغيل المنفّذ لـ {{broker}}",
|
||||
"runnerStoppedFor": "تم إيقاف المنفّذ لـ {{broker}}",
|
||||
"runnerControlFailed": "فشل التحكم في المنفّذ.",
|
||||
"startRunnerTitle": "تشغيل المنفّذ الدائم",
|
||||
"stopRunnerTitle": "إيقاف المنفّذ الدائم",
|
||||
"expiresAt": "تنتهي في {{time}}"
|
||||
},
|
||||
"swarmStatus": {
|
||||
"agents": "{{done}}/{{total}} وكيل",
|
||||
|
||||
@@ -475,7 +475,11 @@
|
||||
"steps": "{{count}} steps",
|
||||
"continue": "Continue",
|
||||
"reattach": "Re-attach"
|
||||
}
|
||||
},
|
||||
"resumeConnector": "Resume connector",
|
||||
"resumeConnectorTitle": "Clear the kill switch and re-enable connector activity",
|
||||
"connectorResumed": "Connector resumed",
|
||||
"failedToResumeConnector": "Failed to resume connector."
|
||||
},
|
||||
"runDetail": {
|
||||
"title": "Run Detail",
|
||||
@@ -527,7 +531,21 @@
|
||||
"noCodeFiles": "No code files.",
|
||||
"fullReport": "Full Report →",
|
||||
"unknown": "unknown",
|
||||
"noneRecorded": "None recorded"
|
||||
"noneRecorded": "None recorded",
|
||||
"tradesCount": "{{count}} trades",
|
||||
"sideSummary": "Buy {{buy}} · Sell {{sell}}",
|
||||
"totalPnl": "Total P&L",
|
||||
"pnl": "P&L",
|
||||
"returnPct": "Return",
|
||||
"holdingDays": "Held (days)",
|
||||
"showMore": "Show {{count}} more",
|
||||
"allSymbols": "All symbols",
|
||||
"sideAll": "All",
|
||||
"sideBuy": "Buy",
|
||||
"sideSell": "Sell",
|
||||
"copyCode": "Copy",
|
||||
"codeCopied": "Code copied",
|
||||
"codeLines": "{{count}} lines"
|
||||
},
|
||||
"compare": {
|
||||
"title": "Strategy Comparison",
|
||||
@@ -557,7 +575,11 @@
|
||||
"exposureTime": "Exposure Time",
|
||||
"avgHoldingPeriod": "Avg Holding Period",
|
||||
"better": "Better",
|
||||
"worse": "Worse"
|
||||
"worse": "Worse",
|
||||
"equityOverlay": "Equity curves",
|
||||
"rebasedMode": "Rebased = 100",
|
||||
"rawMode": "Raw",
|
||||
"swapRuns": "Swap"
|
||||
},
|
||||
"correlation": {
|
||||
"title": "Correlation Matrix",
|
||||
@@ -746,7 +768,13 @@
|
||||
"period2": "Period",
|
||||
"return": "Return",
|
||||
"maxDd": "Max DD",
|
||||
"winRate": "Win Rate"
|
||||
"winRate": "Win Rate",
|
||||
"pathsTitle": "Simulated equity paths",
|
||||
"sharpeDist": "Sharpe distribution",
|
||||
"actualLabel": "Actual",
|
||||
"medianLabel": "Median",
|
||||
"windowReturns": "Per-window return",
|
||||
"tradeNumber": "Trade #"
|
||||
},
|
||||
"thinking": {
|
||||
"processing": "Processing",
|
||||
@@ -782,7 +810,18 @@
|
||||
"never": "never",
|
||||
"unknown": "unknown",
|
||||
"justNow": "just now",
|
||||
"loadingInstructions": "Loading connector authorization instructions..."
|
||||
"loadingInstructions": "Loading connector authorization instructions...",
|
||||
"fallbackInstruction": "Run `vibe-trading connector list`, choose the broker profile, then run `vibe-trading connector authorize <profile>` from the desktop session that will hold the broker connection.",
|
||||
"secondsAgo": "{{n}}s ago",
|
||||
"minutesAgo": "{{n}}m ago",
|
||||
"hoursAgo": "{{n}}h ago",
|
||||
"daysAgo": "{{n}}d ago",
|
||||
"runnerStartedFor": "Runner started for {{broker}}",
|
||||
"runnerStoppedFor": "Runner stopped for {{broker}}",
|
||||
"runnerControlFailed": "Runner control failed.",
|
||||
"startRunnerTitle": "Start the persistent runner",
|
||||
"stopRunnerTitle": "Stop the persistent runner",
|
||||
"expiresAt": "Expires {{time}}"
|
||||
},
|
||||
"swarmStatus": {
|
||||
"agents": "{{done}}/{{total}} agents",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"layout": {
|
||||
"home": "ホーム",
|
||||
"agent": "エージェント",
|
||||
"runtime": "ランタイム",
|
||||
"runtime": "取引モニター",
|
||||
"reports": "レポート",
|
||||
"alphaZoo": "アルファ・ライブラリ",
|
||||
"settings": "設定",
|
||||
@@ -28,14 +28,14 @@
|
||||
"about": "概要"
|
||||
},
|
||||
"runtime": {
|
||||
"monitorBadge": "ランタイムモニター",
|
||||
"title": "ライブ / ペーパー ランタイム状態",
|
||||
"monitorBadge": "取引モニター",
|
||||
"title": "ライブ / ペーパー稼働状況",
|
||||
"subtitlePre": "次からの読み取り専用状態:",
|
||||
"subtitlePost": "。このページではコネクタの認可、Runner の起動/停止、注文の送信/取消、mandate の変更は行いません。",
|
||||
"refresh": "更新",
|
||||
"unavailableTitle": "ランタイム状態を取得できません",
|
||||
"unavailableHint": "バックエンドが現在の状態スナップショットを提供するまで、コネクタランタイムは利用不可として扱ってください。",
|
||||
"statusUnavailable": "ランタイム状態を取得できません。",
|
||||
"unavailableTitle": "稼働状況を取得できません",
|
||||
"unavailableHint": "バックエンドが現在の状態スナップショットを提供するまで、コネクタは利用不可として扱ってください。",
|
||||
"statusUnavailable": "稼働状況を取得できません。",
|
||||
"globalHalt": "グローバル停止",
|
||||
"halted": "停止中",
|
||||
"clear": "クリア",
|
||||
@@ -43,7 +43,7 @@
|
||||
"authorized": "認可済み",
|
||||
"runners": "ランナー",
|
||||
"running": "{{count}} 件実行中",
|
||||
"noProfilesTitle": "ランタイムプロファイルが報告されていません",
|
||||
"noProfilesTitle": "コネクタプロファイルが報告されていません",
|
||||
"noProfilesBody": "バックエンドが認識済みのライブ/ペーパーブローカープロファイルを返しませんでした。",
|
||||
"authPresent": "認可あり",
|
||||
"authMissing": "認可なし",
|
||||
@@ -67,15 +67,15 @@
|
||||
"expired": "期限切れ",
|
||||
"limits": "制限",
|
||||
"noMandate": "有効なマンデートは報告されていません。",
|
||||
"riskStateTitle": "ランタイムリスク状態",
|
||||
"riskStateTitle": "稼働リスク状態",
|
||||
"riskHalted": "リスク停止",
|
||||
"riskHaltedDesc": "このプロファイルではキルスイッチが作動しています。状態がクリアされるまでコネクタランタイムは停止中として扱ってください。",
|
||||
"riskActive": "ランタイム稼働中",
|
||||
"riskHaltedDesc": "このプロファイルではキルスイッチが作動しています。状態がクリアされるまでコネクタは停止中として扱ってください。",
|
||||
"riskActive": "稼働中",
|
||||
"riskActiveDesc": "ランナーが稼働中で、有効期限内のマンデートが存在します。このページは引き続き読み取り専用です。",
|
||||
"riskIdle": "準備完了(アイドル)",
|
||||
"riskIdleDesc": "認可とマンデートは存在しますが、ランナーは稼働していません。",
|
||||
"riskDormant": "休止状態",
|
||||
"riskDormantDesc": "認可・マンデート・ランナーのいずれかが欠けているため、コネクタランタイムは自律運転できる状態にありません。",
|
||||
"riskDormantDesc": "認可・マンデート・ランナーのいずれかが欠けているため、コネクタは自律運転できる状態にありません。",
|
||||
"limitsUnavailable": "制限情報を取得できません",
|
||||
"perOrder": "/注文",
|
||||
"exposure": "エクスポージャー",
|
||||
@@ -425,8 +425,8 @@
|
||||
"failedToLoadGoal": "目標の読み込みに失敗しました。",
|
||||
"cancelRequestSent": "停止しています…",
|
||||
"cancelFailed": "停止できませんでした。もう一度お試しください。",
|
||||
"connectorHalted": "コネクタランタイムが停止しました",
|
||||
"failedToHaltConnector": "コネクタランタイムの停止に失敗しました。",
|
||||
"connectorHalted": "コネクタが停止しました",
|
||||
"failedToHaltConnector": "コネクタの停止に失敗しました。",
|
||||
"cancelledFromUI": "Web UI からキャンセルされました。",
|
||||
"executablesNotAllowed": "実行ファイルとアーカイブはアップロードできません",
|
||||
"fileSizeExceeds": "ファイルサイズが 50 MB の上限を超えています",
|
||||
@@ -434,13 +434,13 @@
|
||||
"uploadFailed": "アップロードに失敗しました: {{error}}",
|
||||
"failedToSend": "メッセージ送信に失敗しました。再試行してください。",
|
||||
"failedToContinue": "目標の続行に失敗しました。再試行してください。",
|
||||
"runtimeBadge": "ランタイム",
|
||||
"runtimeBadge": "取引モニター",
|
||||
"activeResearchGoal": "アクティブなリサーチ目標",
|
||||
"evidenceCollectedTitle": "このリサーチ目標に向けて収集されたエビデンス",
|
||||
"evidenceCount": "{{count}} 件のエビデンス",
|
||||
"uploading": "アップロード中...",
|
||||
"haltConnectorTitle": "コネクタランタイム活動を即座に停止",
|
||||
"haltConnector": "コネクタランタイムを停止",
|
||||
"haltConnectorTitle": "コネクタの活動を即座に停止",
|
||||
"haltConnector": "コネクタを停止",
|
||||
"moreOptions": "その他のオプション",
|
||||
"uploadPdf": "PDF ドキュメントをアップロード",
|
||||
"researchGoal": "リサーチ目標",
|
||||
@@ -470,7 +470,11 @@
|
||||
"steps": "{{count}} ステップ",
|
||||
"continue": "続行",
|
||||
"reattach": "再接続"
|
||||
}
|
||||
},
|
||||
"resumeConnector": "コネクタを再開",
|
||||
"resumeConnectorTitle": "キルスイッチを解除してコネクタ活動を再開",
|
||||
"connectorResumed": "コネクタを再開しました",
|
||||
"failedToResumeConnector": "コネクタの再開に失敗しました。"
|
||||
},
|
||||
"runDetail": {
|
||||
"title": "実行詳細",
|
||||
@@ -522,7 +526,21 @@
|
||||
"noCodeFiles": "コードファイルがありません。",
|
||||
"fullReport": "完全なレポート →",
|
||||
"unknown": "不明",
|
||||
"noneRecorded": "記録なし"
|
||||
"noneRecorded": "記録なし",
|
||||
"tradesCount": "全 {{count}} 件",
|
||||
"sideSummary": "買い {{buy}} · 売り {{sell}}",
|
||||
"totalPnl": "累計損益",
|
||||
"pnl": "損益",
|
||||
"returnPct": "リターン",
|
||||
"holdingDays": "保有日数",
|
||||
"showMore": "さらに {{count}} 件表示",
|
||||
"allSymbols": "すべての銘柄",
|
||||
"sideAll": "すべて",
|
||||
"sideBuy": "買い",
|
||||
"sideSell": "売り",
|
||||
"copyCode": "コピー",
|
||||
"codeCopied": "コードをコピーしました",
|
||||
"codeLines": "{{count}} 行"
|
||||
},
|
||||
"compare": {
|
||||
"title": "戦略比較",
|
||||
@@ -552,7 +570,11 @@
|
||||
"exposureTime": "エクスポージャー時間",
|
||||
"avgHoldingPeriod": "平均保有期間",
|
||||
"better": "良化",
|
||||
"worse": "悪化"
|
||||
"worse": "悪化",
|
||||
"equityOverlay": "資産曲線の比較",
|
||||
"rebasedMode": "基準化 = 100",
|
||||
"rawMode": "元値",
|
||||
"swapRuns": "入れ替え"
|
||||
},
|
||||
"correlation": {
|
||||
"title": "相関行列",
|
||||
@@ -741,7 +763,13 @@
|
||||
"period2": "期間",
|
||||
"return": "リターン",
|
||||
"maxDd": "最大 DD",
|
||||
"winRate": "勝率"
|
||||
"winRate": "勝率",
|
||||
"pathsTitle": "シミュレーション資産経路",
|
||||
"sharpeDist": "シャープレシオ分布",
|
||||
"actualLabel": "実際",
|
||||
"medianLabel": "中央値",
|
||||
"windowReturns": "ウィンドウ別リターン",
|
||||
"tradeNumber": "取引番号"
|
||||
},
|
||||
"thinking": {
|
||||
"processing": "処理中",
|
||||
@@ -750,15 +778,15 @@
|
||||
"steps": "{{count}} ステップ"
|
||||
},
|
||||
"runnerStatus": {
|
||||
"connectorRuntime": "コネクタランタイム",
|
||||
"configuredProfiles": "設定済みコネクタランタイムプロファイル",
|
||||
"connectorRuntime": "コネクタ稼働状況",
|
||||
"configuredProfiles": "設定済みコネクタプロファイル",
|
||||
"connected": "{{count}} 件接続中",
|
||||
"noConnector": "接続中のコネクタなし",
|
||||
"running": "実行中",
|
||||
"halted": "停止中",
|
||||
"authorized": "認可済み",
|
||||
"notConnected": "未接続",
|
||||
"connectProfile": "このプロファイルを接続してコネクタランタイムを有効化",
|
||||
"connectProfile": "このプロファイルを接続してコネクタを有効化",
|
||||
"authorizeNote": "OAuth が成功しマンデートがコミットされるまで、コネクタチャネルは読み取り専用のままです。",
|
||||
"runner": "ランナー",
|
||||
"runnerRunning": "実行中",
|
||||
@@ -768,16 +796,27 @@
|
||||
"expiresIn": "{{time}} 後に期限切れ",
|
||||
"expired": "期限切れ",
|
||||
"limitsUnavailable": "制限情報を取得できません",
|
||||
"noActiveMandate": "有効なマンデートがありません。エージェントに提案してもらい、コミットしてからコネクタランタイムを開始してください。",
|
||||
"noActiveMandate": "有効なマンデートがありません。エージェントに提案してもらい、コミットしてからコネクタを開始してください。",
|
||||
"haltedControlsDisabled": "停止中 — ランナー操作は無効",
|
||||
"runtimeActive": "マンデート内でランタイム稼働中",
|
||||
"runtimeActive": "マンデート内で稼働中",
|
||||
"idle": "アイドル",
|
||||
"stopRunner": "ランナーを停止",
|
||||
"startRunner": "ランナーを開始",
|
||||
"never": "なし",
|
||||
"unknown": "不明",
|
||||
"justNow": "たった今",
|
||||
"loadingInstructions": "コネクタ認可手順を読み込み中..."
|
||||
"loadingInstructions": "コネクタ認可手順を読み込み中...",
|
||||
"fallbackInstruction": "`vibe-trading connector list` を実行してブローカープロファイルを選び、ブローカー接続を保持するデスクトップセッションで `vibe-trading connector authorize <プロファイル>` を実行してください。",
|
||||
"secondsAgo": "{{n}} 秒前",
|
||||
"minutesAgo": "{{n}} 分前",
|
||||
"hoursAgo": "{{n}} 時間前",
|
||||
"daysAgo": "{{n}} 日前",
|
||||
"runnerStartedFor": "{{broker}} のランナーを開始しました",
|
||||
"runnerStoppedFor": "{{broker}} のランナーを停止しました",
|
||||
"runnerControlFailed": "ランナー操作に失敗しました。",
|
||||
"startRunnerTitle": "常駐ランナーを開始",
|
||||
"stopRunnerTitle": "常駐ランナーを停止",
|
||||
"expiresAt": "{{time}} に失効"
|
||||
},
|
||||
"swarmStatus": {
|
||||
"agents": "{{done}}/{{total}} エージェント",
|
||||
@@ -793,7 +832,7 @@
|
||||
"detailsUnavailable": "詳細は利用できません"
|
||||
},
|
||||
"mandate": {
|
||||
"runtimeMandate": "コネクタランタイムマンデート",
|
||||
"runtimeMandate": "コネクタマンデート",
|
||||
"reauthMandate": "コネクタマンデートを再認可",
|
||||
"universe": "ユニバース",
|
||||
"maxOrder": "1 注文最大",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"layout": {
|
||||
"home": "홈",
|
||||
"agent": "에이전트",
|
||||
"runtime": "런타임",
|
||||
"runtime": "거래 모니터",
|
||||
"reports": "리포트",
|
||||
"alphaZoo": "알파 라이브러리",
|
||||
"settings": "설정",
|
||||
@@ -28,14 +28,14 @@
|
||||
"about": "소개"
|
||||
},
|
||||
"runtime": {
|
||||
"monitorBadge": "런타임 모니터",
|
||||
"title": "실거래 / 페이퍼 런타임 상태",
|
||||
"monitorBadge": "거래 모니터",
|
||||
"title": "실거래 / 페이퍼 운영 상태",
|
||||
"subtitlePre": "읽기 전용 상태 소스:",
|
||||
"subtitlePost": ". 이 페이지는 커넥터 인증, 러너 시작/중지, 주문 제출/취소, mandate 변경을 수행하지 않습니다.",
|
||||
"refresh": "새로 고침",
|
||||
"unavailableTitle": "런타임 상태를 사용할 수 없음",
|
||||
"unavailableHint": "백엔드가 현재 상태 스냅샷을 제공할 때까지 커넥터 런타임을 사용 불가로 간주하세요.",
|
||||
"statusUnavailable": "런타임 상태를 사용할 수 없습니다.",
|
||||
"unavailableTitle": "운영 상태를 사용할 수 없음",
|
||||
"unavailableHint": "백엔드가 현재 상태 스냅샷을 제공할 때까지 커넥터를 사용 불가로 간주하세요.",
|
||||
"statusUnavailable": "운영 상태를 사용할 수 없습니다.",
|
||||
"globalHalt": "전체 정지",
|
||||
"halted": "정지됨",
|
||||
"clear": "해제",
|
||||
@@ -43,7 +43,7 @@
|
||||
"authorized": "인증됨",
|
||||
"runners": "러너",
|
||||
"running": "{{count}}개 실행 중",
|
||||
"noProfilesTitle": "보고된 런타임 프로필이 없습니다",
|
||||
"noProfilesTitle": "보고된 커넥터 프로필이 없습니다",
|
||||
"noProfilesBody": "백엔드가 인식된 실거래/페이퍼 브로커 프로필을 반환하지 않았습니다.",
|
||||
"authPresent": "인증 있음",
|
||||
"authMissing": "인증 없음",
|
||||
@@ -67,15 +67,15 @@
|
||||
"expired": "만료됨",
|
||||
"limits": "한도",
|
||||
"noMandate": "활성 mandate가 보고되지 않았습니다.",
|
||||
"riskStateTitle": "런타임 리스크 상태",
|
||||
"riskStateTitle": "운영 리스크 상태",
|
||||
"riskHalted": "리스크 정지",
|
||||
"riskHaltedDesc": "이 프로필의 킬 스위치가 작동했습니다. 상태가 해제될 때까지 커넥터 런타임을 정지된 것으로 간주하세요.",
|
||||
"riskActive": "런타임 활성",
|
||||
"riskHaltedDesc": "이 프로필의 킬 스위치가 작동했습니다. 상태가 해제될 때까지 커넥터를 정지된 것으로 간주하세요.",
|
||||
"riskActive": "운영 중",
|
||||
"riskActiveDesc": "러너가 활성 상태이고 만료되지 않은 mandate가 있습니다. 이 페이지는 여전히 읽기 전용입니다.",
|
||||
"riskIdle": "준비됨(유휴)",
|
||||
"riskIdleDesc": "인증과 mandate가 모두 존재하지만 러너는 활성 상태가 아닙니다.",
|
||||
"riskDormant": "휴면",
|
||||
"riskDormantDesc": "인증, mandate 또는 러너 활성 상태 중 하나가 누락되어 커넥터 런타임이 자율 운영 준비가 되지 않았습니다.",
|
||||
"riskDormantDesc": "인증, mandate 또는 러너 활성 상태 중 하나가 누락되어 커넥터가 자율 운영 준비가 되지 않았습니다.",
|
||||
"limitsUnavailable": "한도 정보 없음",
|
||||
"perOrder": "/주문",
|
||||
"exposure": "익스포저",
|
||||
@@ -425,8 +425,8 @@
|
||||
"failedToLoadGoal": "목표 불러오기에 실패했습니다.",
|
||||
"cancelRequestSent": "중지하는 중…",
|
||||
"cancelFailed": "중지하지 못했습니다. 다시 시도해 주세요.",
|
||||
"connectorHalted": "커넥터 런타임이 정지되었습니다",
|
||||
"failedToHaltConnector": "커넥터 런타임 정지에 실패했습니다.",
|
||||
"connectorHalted": "커넥터가 정지되었습니다",
|
||||
"failedToHaltConnector": "커넥터 정지에 실패했습니다.",
|
||||
"cancelledFromUI": "Web UI에서 취소되었습니다.",
|
||||
"executablesNotAllowed": "실행 파일 및 압축 파일은 업로드할 수 없습니다",
|
||||
"fileSizeExceeds": "파일 크기가 50 MB 한도를 초과합니다",
|
||||
@@ -434,13 +434,13 @@
|
||||
"uploadFailed": "업로드 실패: {{error}}",
|
||||
"failedToSend": "메시지 전송에 실패했습니다. 다시 시도해 주세요.",
|
||||
"failedToContinue": "목표 진행에 실패했습니다. 다시 시도해 주세요.",
|
||||
"runtimeBadge": "런타임",
|
||||
"runtimeBadge": "거래 모니터",
|
||||
"activeResearchGoal": "활성 리서치 목표",
|
||||
"evidenceCollectedTitle": "이 리서치 목표를 위해 수집된 증거",
|
||||
"evidenceCount": "{{count}}개 증거",
|
||||
"uploading": "업로드 중...",
|
||||
"haltConnectorTitle": "커넥터 런타임 활동을 즉시 중지",
|
||||
"haltConnector": "커넥터 런타임 중지",
|
||||
"haltConnectorTitle": "커넥터 활동을 즉시 중지",
|
||||
"haltConnector": "커넥터 중지",
|
||||
"moreOptions": "추가 옵션",
|
||||
"uploadPdf": "PDF 문서 업로드",
|
||||
"researchGoal": "리서치 목표",
|
||||
@@ -470,7 +470,11 @@
|
||||
"steps": "{{count}}단계",
|
||||
"continue": "계속",
|
||||
"reattach": "다시 연결"
|
||||
}
|
||||
},
|
||||
"resumeConnector": "커넥터 재개",
|
||||
"resumeConnectorTitle": "킬 스위치를 해제하고 커넥터 활동을 재개",
|
||||
"connectorResumed": "커넥터가 재개되었습니다",
|
||||
"failedToResumeConnector": "커넥터 재개에 실패했습니다."
|
||||
},
|
||||
"runDetail": {
|
||||
"title": "실행 상세",
|
||||
@@ -522,7 +526,21 @@
|
||||
"noCodeFiles": "코드 파일이 없습니다.",
|
||||
"fullReport": "전체 리포트 →",
|
||||
"unknown": "알 수 없음",
|
||||
"noneRecorded": "기록 없음"
|
||||
"noneRecorded": "기록 없음",
|
||||
"tradesCount": "총 {{count}}건",
|
||||
"sideSummary": "매수 {{buy}} · 매도 {{sell}}",
|
||||
"totalPnl": "총 손익",
|
||||
"pnl": "손익",
|
||||
"returnPct": "수익률",
|
||||
"holdingDays": "보유일수",
|
||||
"showMore": "{{count}}건 더 보기",
|
||||
"allSymbols": "전체 종목",
|
||||
"sideAll": "전체",
|
||||
"sideBuy": "매수",
|
||||
"sideSell": "매도",
|
||||
"copyCode": "복사",
|
||||
"codeCopied": "코드가 복사되었습니다",
|
||||
"codeLines": "{{count}}줄"
|
||||
},
|
||||
"compare": {
|
||||
"title": "전략 비교",
|
||||
@@ -552,7 +570,11 @@
|
||||
"exposureTime": "익스포저 시간",
|
||||
"avgHoldingPeriod": "평균 보유 기간",
|
||||
"better": "개선",
|
||||
"worse": "악화"
|
||||
"worse": "악화",
|
||||
"equityOverlay": "자산 곡선 비교",
|
||||
"rebasedMode": "정규화 = 100",
|
||||
"rawMode": "원본",
|
||||
"swapRuns": "바꾸기"
|
||||
},
|
||||
"correlation": {
|
||||
"title": "상관 행렬",
|
||||
@@ -741,7 +763,13 @@
|
||||
"period2": "기간",
|
||||
"return": "수익률",
|
||||
"maxDd": "최대 DD",
|
||||
"winRate": "승률"
|
||||
"winRate": "승률",
|
||||
"pathsTitle": "시뮬레이션 자산 경로",
|
||||
"sharpeDist": "샤프 비율 분포",
|
||||
"actualLabel": "실제",
|
||||
"medianLabel": "중앙값",
|
||||
"windowReturns": "구간별 수익률",
|
||||
"tradeNumber": "거래 번호"
|
||||
},
|
||||
"thinking": {
|
||||
"processing": "처리 중",
|
||||
@@ -750,15 +778,15 @@
|
||||
"steps": "{{count}}단계"
|
||||
},
|
||||
"runnerStatus": {
|
||||
"connectorRuntime": "커넥터 런타임",
|
||||
"configuredProfiles": "구성된 커넥터 런타임 프로필",
|
||||
"connectorRuntime": "커넥터 운영 상태",
|
||||
"configuredProfiles": "구성된 커넥터 프로필",
|
||||
"connected": "{{count}}개 연결됨",
|
||||
"noConnector": "연결된 커넥터 없음",
|
||||
"running": "실행 중",
|
||||
"halted": "정지됨",
|
||||
"authorized": "인증됨",
|
||||
"notConnected": "연결되지 않음",
|
||||
"connectProfile": "이 프로파일을 연결하여 커넥터 런타임 활성화",
|
||||
"connectProfile": "이 프로필을 연결하여 커넥터 활성화",
|
||||
"authorizeNote": "OAuth가 성공하고 mandate가 커밋될 때까지 커넥터 채널은 읽기 전용 상태를 유지합니다.",
|
||||
"runner": "러너",
|
||||
"runnerRunning": "실행 중",
|
||||
@@ -768,16 +796,27 @@
|
||||
"expiresIn": "{{time}} 후 만료",
|
||||
"expired": "만료됨",
|
||||
"limitsUnavailable": "한도 정보 없음",
|
||||
"noActiveMandate": "활성 mandate가 없습니다. 에이전트에 제안을 요청한 다음, 커넥터 런타임을 시작하기 전에 커밋하세요.",
|
||||
"noActiveMandate": "활성 mandate가 없습니다. 에이전트에 제안을 요청한 다음, 커넥터를 시작하기 전에 커밋하세요.",
|
||||
"haltedControlsDisabled": "정지됨 — 러너 제어 비활성화",
|
||||
"runtimeActive": "mandate 내에서 런타임 활성",
|
||||
"runtimeActive": "mandate 내에서 운영 중",
|
||||
"idle": "유휴",
|
||||
"stopRunner": "러너 중지",
|
||||
"startRunner": "러너 시작",
|
||||
"never": "없음",
|
||||
"unknown": "알 수 없음",
|
||||
"justNow": "방금 전",
|
||||
"loadingInstructions": "커넥터 인증 안내를 불러오는 중..."
|
||||
"loadingInstructions": "커넥터 인증 안내를 불러오는 중...",
|
||||
"fallbackInstruction": "`vibe-trading connector list`를 실행해 브로커 프로필을 선택한 뒤, 브로커 연결을 유지할 데스크톱 세션에서 `vibe-trading connector authorize <프로필>`을 실행하세요.",
|
||||
"secondsAgo": "{{n}}초 전",
|
||||
"minutesAgo": "{{n}}분 전",
|
||||
"hoursAgo": "{{n}}시간 전",
|
||||
"daysAgo": "{{n}}일 전",
|
||||
"runnerStartedFor": "{{broker}} 러너가 시작되었습니다",
|
||||
"runnerStoppedFor": "{{broker}} 러너가 정지되었습니다",
|
||||
"runnerControlFailed": "러너 제어에 실패했습니다.",
|
||||
"startRunnerTitle": "상주 러너 시작",
|
||||
"stopRunnerTitle": "상주 러너 중지",
|
||||
"expiresAt": "{{time}} 만료"
|
||||
},
|
||||
"swarmStatus": {
|
||||
"agents": "{{done}}/{{total}} 에이전트",
|
||||
@@ -793,7 +832,7 @@
|
||||
"detailsUnavailable": "세부 정보 없음"
|
||||
},
|
||||
"mandate": {
|
||||
"runtimeMandate": "커넥터 런타임 mandate",
|
||||
"runtimeMandate": "커넥터 mandate",
|
||||
"reauthMandate": "커넥터 mandate 재인증",
|
||||
"universe": "유니버스",
|
||||
"maxOrder": "주문당 최대",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"layout": {
|
||||
"home": "首页",
|
||||
"agent": "智能体",
|
||||
"runtime": "运行时",
|
||||
"runtime": "交易监控",
|
||||
"reports": "报告",
|
||||
"alphaZoo": "Alpha 因子库",
|
||||
"settings": "设置",
|
||||
@@ -28,14 +28,14 @@
|
||||
"about": "关于"
|
||||
},
|
||||
"runtime": {
|
||||
"monitorBadge": "运行时监控",
|
||||
"title": "实盘 / 模拟运行时状态",
|
||||
"monitorBadge": "交易监控",
|
||||
"title": "实盘 / 模拟运行状态",
|
||||
"subtitlePre": "只读状态,来自",
|
||||
"subtitlePost": "。本页面不会授权连接器、启动或停止执行程序、提交或撤销订单,也不会修改交易授权(mandate)。",
|
||||
"refresh": "刷新",
|
||||
"unavailableTitle": "运行时状态不可用",
|
||||
"unavailableHint": "在后端提供最新状态快照前,请将连接器运行时视为不可用。",
|
||||
"statusUnavailable": "运行时状态不可用。",
|
||||
"unavailableTitle": "运行状态不可用",
|
||||
"unavailableHint": "在后端提供最新状态快照前,请将连接器视为不可用。",
|
||||
"statusUnavailable": "运行状态不可用。",
|
||||
"globalHalt": "全局熔断",
|
||||
"halted": "已熔断",
|
||||
"clear": "正常",
|
||||
@@ -43,7 +43,7 @@
|
||||
"authorized": "已授权",
|
||||
"runners": "执行程序",
|
||||
"running": "{{count}} 个运行中",
|
||||
"noProfilesTitle": "未报告任何运行时档案",
|
||||
"noProfilesTitle": "未报告任何连接器档案",
|
||||
"noProfilesBody": "后端未返回任何已识别的实盘或模拟券商档案。",
|
||||
"authPresent": "已授权",
|
||||
"authMissing": "未授权",
|
||||
@@ -67,15 +67,15 @@
|
||||
"expired": "已过期",
|
||||
"limits": "限额",
|
||||
"noMandate": "未报告活跃交易授权(mandate)。",
|
||||
"riskStateTitle": "运行时风险状态",
|
||||
"riskStateTitle": "运行风险状态",
|
||||
"riskHalted": "风险熔断",
|
||||
"riskHaltedDesc": "该档案的熔断开关已触发。在状态恢复前请将连接器运行时视为已停止。",
|
||||
"riskActive": "运行时活跃",
|
||||
"riskHaltedDesc": "该档案的熔断开关已触发。在状态恢复前请将连接器视为已停止。",
|
||||
"riskActive": "运行中",
|
||||
"riskActiveDesc": "执行程序存活且存在未过期的交易授权(mandate)。本页面仍仅为只读状态。",
|
||||
"riskIdle": "就绪但空闲",
|
||||
"riskIdleDesc": "授权与交易授权(mandate)均存在,但执行程序未存活。",
|
||||
"riskDormant": "休眠",
|
||||
"riskDormantDesc": "由于缺少授权、交易授权(mandate)或执行程序存活状态,连接器运行时尚未就绪以进行自主运行。",
|
||||
"riskDormantDesc": "由于缺少授权、交易授权(mandate)或执行程序存活状态,连接器尚未就绪以进行自主运行。",
|
||||
"limitsUnavailable": "限额不可用",
|
||||
"perOrder": "/单",
|
||||
"exposure": "敞口",
|
||||
@@ -425,8 +425,8 @@
|
||||
"failedToLoadGoal": "加载目标失败。",
|
||||
"cancelRequestSent": "正在停止…",
|
||||
"cancelFailed": "没能停下来,请再点一次。",
|
||||
"connectorHalted": "连接器运行时已停止",
|
||||
"failedToHaltConnector": "停止连接器运行时失败。",
|
||||
"connectorHalted": "连接器已停止",
|
||||
"failedToHaltConnector": "停止连接器失败。",
|
||||
"cancelledFromUI": "已从 Web UI 取消。",
|
||||
"executablesNotAllowed": "不允许上传可执行文件和压缩包",
|
||||
"fileSizeExceeds": "文件大小超过 50 MB 限制",
|
||||
@@ -434,13 +434,13 @@
|
||||
"uploadFailed": "上传失败:{{error}}",
|
||||
"failedToSend": "发送消息失败,请重试。",
|
||||
"failedToContinue": "继续目标失败,请重试。",
|
||||
"runtimeBadge": "运行时",
|
||||
"runtimeBadge": "交易监控",
|
||||
"activeResearchGoal": "活跃的研究目标",
|
||||
"evidenceCollectedTitle": "为此研究目标收集的证据",
|
||||
"evidenceCount": "{{count}} 条证据",
|
||||
"uploading": "上传中...",
|
||||
"haltConnectorTitle": "立即停止连接器运行时活动",
|
||||
"haltConnector": "停止连接器运行时",
|
||||
"haltConnectorTitle": "立即停止连接器活动",
|
||||
"haltConnector": "停止连接器",
|
||||
"moreOptions": "更多选项",
|
||||
"uploadPdf": "上传 PDF 文档",
|
||||
"researchGoal": "研究目标",
|
||||
@@ -470,7 +470,11 @@
|
||||
"steps": "{{count}} 个步骤",
|
||||
"continue": "继续",
|
||||
"reattach": "重新连接"
|
||||
}
|
||||
},
|
||||
"resumeConnector": "恢复连接器",
|
||||
"resumeConnectorTitle": "清除熔断开关并恢复连接器活动",
|
||||
"connectorResumed": "连接器已恢复",
|
||||
"failedToResumeConnector": "恢复连接器失败。"
|
||||
},
|
||||
"runDetail": {
|
||||
"title": "运行详情",
|
||||
@@ -522,7 +526,21 @@
|
||||
"noCodeFiles": "暂无代码文件。",
|
||||
"fullReport": "完整报告 →",
|
||||
"unknown": "未知",
|
||||
"noneRecorded": "未记录"
|
||||
"noneRecorded": "未记录",
|
||||
"tradesCount": "共 {{count}} 笔",
|
||||
"sideSummary": "买 {{buy}} · 卖 {{sell}}",
|
||||
"totalPnl": "总盈亏",
|
||||
"pnl": "盈亏",
|
||||
"returnPct": "收益率",
|
||||
"holdingDays": "持有天数",
|
||||
"showMore": "再显示 {{count}} 条",
|
||||
"allSymbols": "全部标的",
|
||||
"sideAll": "全部",
|
||||
"sideBuy": "买入",
|
||||
"sideSell": "卖出",
|
||||
"copyCode": "复制",
|
||||
"codeCopied": "代码已复制",
|
||||
"codeLines": "{{count}} 行"
|
||||
},
|
||||
"compare": {
|
||||
"title": "策略对比",
|
||||
@@ -552,7 +570,11 @@
|
||||
"exposureTime": "持仓时间",
|
||||
"avgHoldingPeriod": "平均持仓天数",
|
||||
"better": "表现更优",
|
||||
"worse": "表现更差"
|
||||
"worse": "表现更差",
|
||||
"equityOverlay": "净值曲线对比",
|
||||
"rebasedMode": "归一 = 100",
|
||||
"rawMode": "原始",
|
||||
"swapRuns": "交换"
|
||||
},
|
||||
"correlation": {
|
||||
"title": "相关性矩阵",
|
||||
@@ -741,7 +763,13 @@
|
||||
"period2": "时段",
|
||||
"return": "收益",
|
||||
"maxDd": "最大回撤",
|
||||
"winRate": "胜率"
|
||||
"winRate": "胜率",
|
||||
"pathsTitle": "模拟资金路径",
|
||||
"sharpeDist": "夏普比率分布",
|
||||
"actualLabel": "实际",
|
||||
"medianLabel": "中位数",
|
||||
"windowReturns": "逐窗口收益",
|
||||
"tradeNumber": "交易序号"
|
||||
},
|
||||
"thinking": {
|
||||
"processing": "处理中",
|
||||
@@ -750,15 +778,15 @@
|
||||
"steps": "{{count}} 个步骤"
|
||||
},
|
||||
"runnerStatus": {
|
||||
"connectorRuntime": "连接器运行时",
|
||||
"configuredProfiles": "已配置的连接器运行时配置",
|
||||
"connectorRuntime": "连接器运行状态",
|
||||
"configuredProfiles": "已配置的连接器档案",
|
||||
"connected": "{{count}} 个已连接",
|
||||
"noConnector": "无连接器已连接",
|
||||
"running": "运行中",
|
||||
"halted": "已停止",
|
||||
"authorized": "已授权",
|
||||
"notConnected": "未连接",
|
||||
"connectProfile": "连接此配置以启用连接器运行时",
|
||||
"connectProfile": "连接此档案以启动连接器",
|
||||
"authorizeNote": "在 OAuth 授权成功且提交交易授权(mandate)之前,连接器通道保持只读。",
|
||||
"runner": "运行器",
|
||||
"runnerRunning": "运行中",
|
||||
@@ -768,16 +796,27 @@
|
||||
"expiresIn": "{{time}} 后过期",
|
||||
"expired": "已过期",
|
||||
"limitsUnavailable": "限制信息不可用",
|
||||
"noActiveMandate": "无活跃交易授权(mandate)。请让智能体提议一个,然后在启动连接器运行时之前提交。",
|
||||
"noActiveMandate": "无活跃交易授权(mandate)。请让智能体提议一个,然后在启动连接器之前提交。",
|
||||
"haltedControlsDisabled": "已停止 — 运行器控制已禁用",
|
||||
"runtimeActive": "运行时在交易授权(mandate)内活跃",
|
||||
"runtimeActive": "连接器在交易授权(mandate)内运行中",
|
||||
"idle": "空闲",
|
||||
"stopRunner": "停止运行器",
|
||||
"startRunner": "启动运行器",
|
||||
"never": "从未",
|
||||
"unknown": "未知",
|
||||
"justNow": "刚刚",
|
||||
"loadingInstructions": "正在加载连接器授权说明..."
|
||||
"loadingInstructions": "正在加载连接器授权说明...",
|
||||
"fallbackInstruction": "运行 `vibe-trading connector list` 选择券商档案,然后在持有券商连接的桌面会话中运行 `vibe-trading connector authorize <档案>`。",
|
||||
"secondsAgo": "{{n}} 秒前",
|
||||
"minutesAgo": "{{n}} 分钟前",
|
||||
"hoursAgo": "{{n}} 小时前",
|
||||
"daysAgo": "{{n}} 天前",
|
||||
"runnerStartedFor": "{{broker}} 执行程序已启动",
|
||||
"runnerStoppedFor": "{{broker}} 执行程序已停止",
|
||||
"runnerControlFailed": "执行程序控制失败。",
|
||||
"startRunnerTitle": "启动常驻执行程序",
|
||||
"stopRunnerTitle": "停止常驻执行程序",
|
||||
"expiresAt": "{{time}} 到期"
|
||||
},
|
||||
"swarmStatus": {
|
||||
"agents": "{{done}}/{{total}} 个智能体",
|
||||
@@ -793,7 +832,7 @@
|
||||
"detailsUnavailable": "暂无详细信息"
|
||||
},
|
||||
"mandate": {
|
||||
"runtimeMandate": "连接器运行时交易授权(mandate)",
|
||||
"runtimeMandate": "连接器交易授权(mandate)",
|
||||
"reauthMandate": "重新授权连接器交易授权(mandate)",
|
||||
"universe": "标的池",
|
||||
"maxOrder": "单笔最大",
|
||||
|
||||
+14
-12
@@ -43,23 +43,25 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark mode: 4-level surface system */
|
||||
/* Dark mode: 4-level surface system. Keep >=5 lightness points between
|
||||
adjacent surfaces — smaller deltas are indistinguishable on most
|
||||
panels (sidebar vs main blended at 5%/8%). */
|
||||
--background: 220 20% 5%; /* bg-base */
|
||||
--foreground: 220 10% 92%;
|
||||
--card: 220 20% 8%; /* surface-1 */
|
||||
--card-foreground: 220 10% 92%;
|
||||
--foreground: 220 10% 93%;
|
||||
--card: 220 18% 10%; /* surface-1 */
|
||||
--card-foreground: 220 10% 93%;
|
||||
|
||||
--popover: 220 20% 9%; /* surface-1.5 (slightly lighter than card) */
|
||||
--popover-foreground: 220 10% 92%;
|
||||
--popover: 220 18% 12%; /* surface-1.5 (slightly lighter than card) */
|
||||
--popover-foreground: 220 10% 93%;
|
||||
|
||||
--primary: 27 90% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--muted: 220 20% 13%; /* surface-2 */
|
||||
--muted-foreground: 220 10% 55%;
|
||||
--muted: 220 16% 16%; /* surface-2 */
|
||||
--muted-foreground: 220 10% 64%;
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 20% 18%; /* surface-3 */
|
||||
--border: 220 14% 24%; /* surface-3 */
|
||||
|
||||
/* Semantic colors */
|
||||
--success: 142 71% 45%;
|
||||
@@ -68,9 +70,9 @@
|
||||
--info: 217 91% 60%;
|
||||
|
||||
/* Chart tokens */
|
||||
--chart-grid: 220 20% 12%;
|
||||
--chart-text: 220 10% 42%;
|
||||
--chart-axis: 220 20% 22%;
|
||||
--chart-grid: 220 20% 14%;
|
||||
--chart-text: 220 10% 55%;
|
||||
--chart-axis: 220 20% 26%;
|
||||
--chart-compare-a: #60a5fa;
|
||||
--chart-compare-b: #fbbf24;
|
||||
}
|
||||
|
||||
@@ -251,6 +251,11 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ session_id, broker, reason }),
|
||||
}),
|
||||
resumeLive: (session_id?: string, broker?: string) =>
|
||||
request<HaltLiveResponse>("/live/resume", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ session_id, broker }),
|
||||
}),
|
||||
// Read the persistent runtime status across all authorized brokers (SPEC §7.5).
|
||||
// Polled by the RunnerStatus panel; a plain authenticated GET, never a chat message.
|
||||
getLiveStatus: (signal?: AbortSignal) => request<LiveStatus>("/live/status", { signal }),
|
||||
@@ -432,6 +437,19 @@ export interface EquityPoint {
|
||||
drawdown: string | number;
|
||||
}
|
||||
|
||||
/** Monte Carlo fan-chart payload: percentile envelope + sampled paths over trade order. */
|
||||
export interface MonteCarloEquityPaths {
|
||||
steps: number[];
|
||||
initial_capital: number;
|
||||
actual: number[];
|
||||
band_p5: number[];
|
||||
band_p25: number[];
|
||||
band_p50: number[];
|
||||
band_p75: number[];
|
||||
band_p95: number[];
|
||||
samples: number[][];
|
||||
}
|
||||
|
||||
export interface ValidationData {
|
||||
monte_carlo?: {
|
||||
actual_sharpe: number;
|
||||
@@ -444,6 +462,8 @@ export interface ValidationData {
|
||||
simulated_sharpe_p95: number;
|
||||
n_simulations: number;
|
||||
n_trades: number;
|
||||
sharpe_samples?: number[];
|
||||
equity_paths?: MonteCarloEquityPaths;
|
||||
error?: string;
|
||||
};
|
||||
bootstrap?: {
|
||||
@@ -454,6 +474,7 @@ export interface ValidationData {
|
||||
prob_positive: number;
|
||||
confidence: number;
|
||||
n_bootstrap: number;
|
||||
sharpe_samples?: number[];
|
||||
error?: string;
|
||||
};
|
||||
walk_forward?: {
|
||||
|
||||
@@ -1510,7 +1510,7 @@ export function Agent() {
|
||||
<div
|
||||
className={[
|
||||
"max-w-3xl mx-auto space-y-8",
|
||||
!sessionLoading && messages.length === 0 ? "min-h-full flex flex-col justify-end" : "",
|
||||
!sessionLoading && messages.length === 0 ? "min-h-full flex flex-col" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionLoading && (
|
||||
@@ -1527,7 +1527,9 @@ export function Agent() {
|
||||
</div>
|
||||
)}
|
||||
{!sessionLoading && messages.length === 0 && (
|
||||
<div className="msg-enter">
|
||||
// my-auto (not justify-*) centers the hero vertically while staying
|
||||
// scroll-reachable once the example library expands past the viewport.
|
||||
<div className="msg-enter my-auto">
|
||||
<WelcomeScreen onExample={fillComposer} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import i18n from '@/i18n';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { GitCompare, ArrowRight } from "lucide-react";
|
||||
import { GitCompare, ArrowLeftRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api, type RunListItem, type RunData, type EquityPoint } from "@/lib/api";
|
||||
import { echarts, CHART_GROUP, connectCharts } from "@/lib/echarts";
|
||||
@@ -112,9 +112,12 @@ interface EquityChartOverlayProps {
|
||||
rightCurve: EquityPoint[];
|
||||
leftLabel: string;
|
||||
rightLabel: string;
|
||||
/** Rebase both curves to 100 at their first bar — runs with different
|
||||
* initial capital are only comparable on an indexed scale. */
|
||||
rebase: boolean;
|
||||
}
|
||||
|
||||
function EquityChartOverlay({ leftCurve, rightCurve, leftLabel, rightLabel }: EquityChartOverlayProps) {
|
||||
function EquityChartOverlay({ leftCurve, rightCurve, leftLabel, rightLabel, rebase }: EquityChartOverlayProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dark = useThemeDark();
|
||||
|
||||
@@ -133,9 +136,13 @@ function EquityChartOverlay({ leftCurve, rightCurve, leftLabel, rightLabel }: Eq
|
||||
for (const p of rightCurve) dateSet.add(p.time);
|
||||
const dates = Array.from(dateSet).sort();
|
||||
|
||||
// Build lookup maps
|
||||
const leftMap = new Map(leftCurve.map((p) => [p.time, Number(p.equity)]));
|
||||
const rightMap = new Map(rightCurve.map((p) => [p.time, Number(p.equity)]));
|
||||
// Build lookup maps, optionally rebased to an index of 100
|
||||
const leftBase = Number(leftCurve[0]?.equity) || 0;
|
||||
const rightBase = Number(rightCurve[0]?.equity) || 0;
|
||||
const scale = (raw: number, base: number) =>
|
||||
rebase && base > 0 ? Number(((raw / base) * 100).toFixed(2)) : raw;
|
||||
const leftMap = new Map(leftCurve.map((p) => [p.time, scale(Number(p.equity), leftBase)]));
|
||||
const rightMap = new Map(rightCurve.map((p) => [p.time, scale(Number(p.equity), rightBase)]));
|
||||
|
||||
const leftData = dates.map((d) => leftMap.get(d) ?? null);
|
||||
const rightData = dates.map((d) => rightMap.get(d) ?? null);
|
||||
@@ -157,7 +164,8 @@ function EquityChartOverlay({ leftCurve, rightCurve, leftLabel, rightLabel }: Eq
|
||||
let html = `<b>${params[0].axisValue}</b>`;
|
||||
for (const p of params) {
|
||||
if (p.value == null) continue;
|
||||
html += `<br/>${p.marker} ${p.seriesName}: <b>${Number(p.value).toLocaleString()}</b>`;
|
||||
const value = rebase ? Number(p.value).toFixed(2) : Number(p.value).toLocaleString();
|
||||
html += `<br/>${p.marker} ${p.seriesName}: <b>${value}</b>`;
|
||||
}
|
||||
return html;
|
||||
},
|
||||
@@ -217,7 +225,7 @@ function EquityChartOverlay({ leftCurve, rightCurve, leftLabel, rightLabel }: Eq
|
||||
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
|
||||
chart.dispose();
|
||||
};
|
||||
}, [leftCurve, rightCurve, leftLabel, rightLabel, dark]);
|
||||
}, [leftCurve, rightCurve, leftLabel, rightLabel, rebase, dark]);
|
||||
|
||||
if (leftCurve.length === 0 && rightCurve.length === 0) return null;
|
||||
|
||||
@@ -235,6 +243,7 @@ export function Compare() {
|
||||
const [rightCurve, setRightCurve] = useState<EquityPoint[]>([]);
|
||||
const [leftLoading, setLeftLoading] = useState(false);
|
||||
const [rightLoading, setRightLoading] = useState(false);
|
||||
const [rebase, setRebase] = useState(true);
|
||||
const leftRequestGeneration = useRef(0);
|
||||
const rightRequestGeneration = useRef(0);
|
||||
|
||||
@@ -316,7 +325,7 @@ export function Compare() {
|
||||
const hasData = Boolean(leftData || rightData);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl space-y-6">
|
||||
<div className="mx-auto max-w-5xl p-8 space-y-6">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold">
|
||||
<GitCompare className="h-5 w-5" aria-hidden="true" /> {t("compare.title")}
|
||||
</h1>
|
||||
@@ -330,7 +339,15 @@ export function Compare() {
|
||||
{runs.map((r) => <option key={r.run_id} value={r.run_id}>{runLabel(r)} ({r.status})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-muted-foreground mb-2 shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLeftId(rightId); setRightId(leftId); }}
|
||||
title={i18n.t("compare.swapRuns")}
|
||||
className="mb-1 shrink-0 rounded-md border border-border/60 p-2 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">{i18n.t("compare.swapRuns")}</span>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground block mb-1">{i18n.t("compare.compare")}</label>
|
||||
<select value={rightId} onChange={(e) => setRightId(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" title={rightRun?.prompt || rightId}>
|
||||
@@ -356,12 +373,35 @@ export function Compare() {
|
||||
{/* Equity curve overlay */}
|
||||
{(leftCurve.length > 0 || rightCurve.length > 0) && (
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">{i18n.t("compare.equityDrawdown")}</h2>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">{i18n.t("compare.equityOverlay")}</h2>
|
||||
<div className="flex gap-1 text-xs" role="group">
|
||||
{[
|
||||
{ id: true, label: i18n.t("compare.rebasedMode") },
|
||||
{ id: false, label: i18n.t("compare.rawMode") },
|
||||
].map((mode) => (
|
||||
<button
|
||||
key={String(mode.id)}
|
||||
type="button"
|
||||
onClick={() => setRebase(mode.id)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 transition-colors",
|
||||
rebase === mode.id
|
||||
? "border-primary/30 bg-primary/10 font-medium text-primary"
|
||||
: "border-border/60 text-muted-foreground hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<EquityChartOverlay
|
||||
leftCurve={leftCurve}
|
||||
rightCurve={rightCurve}
|
||||
leftLabel={leftRun ? truncatePrompt(leftRun.prompt, 20) || t("compare.baseline") : t("compare.baseline")}
|
||||
rightLabel={rightRun ? truncatePrompt(rightRun.prompt, 20) || t("compare.compare") : t("compare.compare")}
|
||||
rebase={rebase}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -383,11 +423,38 @@ export function Compare() {
|
||||
const lv = resolveMetric(leftData, key);
|
||||
const rv = resolveMetric(rightData, key);
|
||||
const diff = diffClass(lv, rv, higherIsBetter);
|
||||
// Proportional in-cell bars (colored to match the chart legend)
|
||||
// make the winner readable at a glance without parsing numbers.
|
||||
const la = Number.isFinite(Number(lv)) ? Math.abs(Number(lv)) : null;
|
||||
const ra = Number.isFinite(Number(rv)) ? Math.abs(Number(rv)) : null;
|
||||
const maxAbs = Math.max(la ?? 0, ra ?? 0);
|
||||
const shareL = la != null && maxAbs > 0 ? la / maxAbs : 0;
|
||||
const shareR = ra != null && maxAbs > 0 ? ra / maxAbs : 0;
|
||||
const leftWins = diff.verdict === "worse";
|
||||
const rightWins = diff.verdict === "better";
|
||||
return (
|
||||
<tr key={key} className="border-b last:border-0 hover:bg-muted/40">
|
||||
<td className="px-4 py-2.5 font-medium">{label}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">{fmt(lv, type)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">{fmt(rv, type)}</td>
|
||||
<td className="relative px-4 py-2.5 text-right font-mono tabular-nums">
|
||||
{shareL > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-1.5 end-2 rounded-sm"
|
||||
style={{ width: `${shareL * 55}%`, backgroundColor: "var(--chart-compare-a)", opacity: 0.14 }}
|
||||
/>
|
||||
)}
|
||||
<span className={cn("relative", leftWins && "font-semibold")}>{fmt(lv, type)}</span>
|
||||
</td>
|
||||
<td className="relative px-4 py-2.5 text-right font-mono tabular-nums">
|
||||
{shareR > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-1.5 end-2 rounded-sm"
|
||||
style={{ width: `${shareR * 55}%`, backgroundColor: "var(--chart-compare-b)", opacity: 0.14 }}
|
||||
/>
|
||||
)}
|
||||
<span className={cn("relative", rightWins && "font-semibold")}>{fmt(rv, type)}</span>
|
||||
</td>
|
||||
<td className={cn("px-4 py-2.5 text-right font-mono tabular-nums font-semibold", diff.className)}>
|
||||
{diff.verdict && (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
Code2,
|
||||
Copy,
|
||||
Database,
|
||||
Download,
|
||||
FileCheck2,
|
||||
@@ -18,8 +19,9 @@ import {
|
||||
ShieldCheck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api, type BacktestMetrics, type RunCard, type RunData } from "@/lib/api";
|
||||
import { api, type BacktestMetrics, type RunCard, type RunData, type ValidationData } from "@/lib/api";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import { CandlestickChart } from "@/components/charts/CandlestickChart";
|
||||
@@ -425,9 +427,13 @@ function RunCardTab({ card }: { card: RunCard }) {
|
||||
</RunCardPanel>
|
||||
<RunCardPanel title={i18n.t("runDetail.validationPayload")} icon={ShieldCheck}>
|
||||
{card.validation ? (
|
||||
<pre className="max-h-80 overflow-auto rounded-md bg-muted/40 p-3 text-xs leading-relaxed">
|
||||
{JSON.stringify(card.validation, null, 2)}
|
||||
</pre>
|
||||
hasStructuredValidation(card.validation) ? (
|
||||
<ValidationPanel data={card.validation as unknown as ValidationData} compact />
|
||||
) : (
|
||||
<pre className="max-h-80 overflow-auto rounded-md bg-muted/40 p-3 text-xs leading-relaxed">
|
||||
{JSON.stringify(card.validation, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{i18n.t("runDetail.noValidationPayload")}</p>
|
||||
)}
|
||||
@@ -506,6 +512,12 @@ function KeyValueTable({ data, empty, monospaceValues = false }: { data: Record<
|
||||
);
|
||||
}
|
||||
|
||||
function hasStructuredValidation(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return Boolean(v.monte_carlo || v.bootstrap || v.walk_forward);
|
||||
}
|
||||
|
||||
function formatRunCardValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
if (typeof value === "number") return Number.isInteger(value) ? String(value) : value.toFixed(4);
|
||||
@@ -666,11 +678,107 @@ function ChartTab({
|
||||
);
|
||||
}
|
||||
|
||||
const TRADES_PAGE_SIZE = 100;
|
||||
|
||||
function normalizeSide(value?: string): "BUY" | "SELL" | "" {
|
||||
const side = (value || "").trim().toUpperCase();
|
||||
if (side.startsWith("B")) return "BUY";
|
||||
if (side.startsWith("S")) return "SELL";
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseTradeNumber(value?: string): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function signedNumberClass(value: number | null): string {
|
||||
if (value == null || value === 0) return "text-muted-foreground";
|
||||
return value > 0 ? "text-success" : "text-danger";
|
||||
}
|
||||
|
||||
function formatSigned(value: number, suffix = ""): string {
|
||||
return `${value > 0 ? "+" : ""}${value.toFixed(2)}${suffix}`;
|
||||
}
|
||||
|
||||
function TradesTab({ run }: { run: RunData }) {
|
||||
const trades = run.trade_log || [];
|
||||
const [sideFilter, setSideFilter] = useState<"" | "BUY" | "SELL">("");
|
||||
const [symbolFilter, setSymbolFilter] = useState("");
|
||||
const [visibleCount, setVisibleCount] = useState(TRADES_PAGE_SIZE);
|
||||
if (trades.length === 0) return <div className="p-8 text-muted-foreground text-sm">{i18n.t("runDetail.noTrades")}</div>;
|
||||
|
||||
const symbols = [...new Set(trades.map((tr) => tr.code).filter(Boolean))];
|
||||
const hasPnl = trades.some((tr) => parseTradeNumber(tr.pnl) != null);
|
||||
const hasReturnPct = trades.some((tr) => parseTradeNumber(tr.return_pct) != null);
|
||||
const hasHoldingDays = trades.some((tr) => (tr.holding_days ?? "") !== "");
|
||||
|
||||
const filtered = trades.filter((tr) => (
|
||||
(!sideFilter || normalizeSide(tr.side) === sideFilter)
|
||||
&& (!symbolFilter || tr.code === symbolFilter)
|
||||
));
|
||||
const buys = filtered.filter((tr) => normalizeSide(tr.side) === "BUY").length;
|
||||
const sells = filtered.filter((tr) => normalizeSide(tr.side) === "SELL").length;
|
||||
const totalPnl = hasPnl
|
||||
? filtered.reduce((sum, tr) => sum + (parseTradeNumber(tr.pnl) ?? 0), 0)
|
||||
: null;
|
||||
const visible = filtered.slice(0, visibleCount);
|
||||
const remaining = filtered.length - visible.length;
|
||||
|
||||
const sideChips: { id: "" | "BUY" | "SELL"; label: string }[] = [
|
||||
{ id: "", label: i18n.t("runDetail.sideAll") },
|
||||
{ id: "BUY", label: i18n.t("runDetail.sideBuy") },
|
||||
{ id: "SELL", label: i18n.t("runDetail.sideSell") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{i18n.t("runDetail.tradesCount", { count: filtered.length })}</span>
|
||||
<span>{i18n.t("runDetail.sideSummary", { buy: buys, sell: sells })}</span>
|
||||
{totalPnl != null && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{i18n.t("runDetail.totalPnl")}
|
||||
<span className={cn("font-mono font-medium tabular-nums", signedNumberClass(totalPnl))}>
|
||||
{formatSigned(totalPnl)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="ms-auto flex flex-wrap items-center gap-1.5">
|
||||
<div className="flex gap-1" role="group">
|
||||
{sideChips.map((chip) => (
|
||||
<button
|
||||
key={chip.id || "all"}
|
||||
type="button"
|
||||
onClick={() => { setSideFilter(chip.id); setVisibleCount(TRADES_PAGE_SIZE); }}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 transition-colors",
|
||||
sideFilter === chip.id
|
||||
? "border-primary/30 bg-primary/10 font-medium text-primary"
|
||||
: "border-border/60 hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{symbols.length > 1 && (
|
||||
<select
|
||||
value={symbolFilter}
|
||||
onChange={(event) => { setSymbolFilter(event.target.value); setVisibleCount(TRADES_PAGE_SIZE); }}
|
||||
className="h-7 rounded-md border border-border/60 bg-background px-2 text-xs"
|
||||
aria-label={i18n.t("runDetail.symbol")}
|
||||
>
|
||||
<option value="">{i18n.t("runDetail.allSymbols")}</option>
|
||||
{symbols.map((symbol) => (
|
||||
<option key={symbol} value={symbol}>{symbol}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border/60 bg-card shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -678,25 +786,67 @@ function TradesTab({ run }: { run: RunData }) {
|
||||
<th className="py-2 ps-4 pr-4">{i18n.t("runDetail.time")}</th>
|
||||
<th className="py-2 pr-4">{i18n.t("runDetail.code2")}</th>
|
||||
<th className="py-2 pr-4">{i18n.t("runDetail.side")}</th>
|
||||
<th className="py-2 pr-4">{i18n.t("runDetail.price")}</th>
|
||||
<th className="py-2 pr-4">{i18n.t("runDetail.qty")}</th>
|
||||
<th className="py-2 pr-4 text-right">{i18n.t("runDetail.price")}</th>
|
||||
<th className="py-2 pr-4 text-right">{i18n.t("runDetail.qty")}</th>
|
||||
{hasPnl && <th className="py-2 pr-4 text-right">{i18n.t("runDetail.pnl")}</th>}
|
||||
{hasReturnPct && <th className="py-2 pr-4 text-right">{i18n.t("runDetail.returnPct")}</th>}
|
||||
{hasHoldingDays && <th className="py-2 pr-4 text-right">{i18n.t("runDetail.holdingDays")}</th>}
|
||||
<th className="py-2">{i18n.t("runDetail.reason")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((tr, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/40">
|
||||
<td className="py-2 ps-4 pr-4 font-mono text-xs">{tr.time || tr.timestamp}</td>
|
||||
<td className="py-2 pr-4">{tr.code}</td>
|
||||
<td className={cn("py-2 pr-4 font-medium", tr.side === "BUY" ? "text-success" : "text-danger")}>{tr.side}</td>
|
||||
<td className="py-2 pr-4 font-mono tabular-nums">{tr.price}</td>
|
||||
<td className="py-2 pr-4 font-mono tabular-nums">{tr.qty}</td>
|
||||
<td className="py-2 text-muted-foreground">{tr.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
{visible.map((tr, i) => {
|
||||
const side = normalizeSide(tr.side);
|
||||
const pnl = parseTradeNumber(tr.pnl);
|
||||
const returnPct = parseTradeNumber(tr.return_pct);
|
||||
return (
|
||||
<tr key={i} className={cn("border-b last:border-0 hover:bg-muted/40", i % 2 === 1 && "bg-muted/10")}>
|
||||
<td className="py-2 ps-4 pr-4 font-mono text-xs">{tr.time || tr.timestamp}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{tr.code}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={cn(
|
||||
"inline-block rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
side === "BUY" && "bg-success/10 text-success",
|
||||
side === "SELL" && "bg-danger/10 text-danger",
|
||||
side === "" && "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{side === "BUY" ? i18n.t("runDetail.sideBuy") : side === "SELL" ? i18n.t("runDetail.sideSell") : tr.side}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right font-mono tabular-nums">{tr.price}</td>
|
||||
<td className="py-2 pr-4 text-right font-mono tabular-nums">{tr.qty}</td>
|
||||
{hasPnl && (
|
||||
<td className={cn("py-2 pr-4 text-right font-mono tabular-nums", signedNumberClass(pnl))}>
|
||||
{pnl != null ? formatSigned(pnl) : "—"}
|
||||
</td>
|
||||
)}
|
||||
{hasReturnPct && (
|
||||
<td className={cn("py-2 pr-4 text-right font-mono tabular-nums", signedNumberClass(returnPct))}>
|
||||
{returnPct != null ? formatSigned(returnPct, "%") : "—"}
|
||||
</td>
|
||||
)}
|
||||
{hasHoldingDays && (
|
||||
<td className="py-2 pr-4 text-right font-mono tabular-nums text-muted-foreground">{tr.holding_days ?? "—"}</td>
|
||||
)}
|
||||
<td className="py-2 text-xs text-muted-foreground">{tr.reason}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{remaining > 0 && (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleCount((count) => count + TRADES_PAGE_SIZE)}
|
||||
className="rounded-full border border-border/60 px-4 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
{i18n.t("runDetail.showMore", { count: Math.min(remaining, TRADES_PAGE_SIZE) })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -705,16 +855,40 @@ function CodeTab({ code }: { code: Record<string, string> }) {
|
||||
const files = Object.entries(code);
|
||||
const [active, setActive] = useState(files[0]?.[0] || "");
|
||||
if (files.length === 0) return <div className="p-8 text-muted-foreground text-sm">{i18n.t("runDetail.noCodeFiles")}</div>;
|
||||
|
||||
const activeCode = code[active] || "";
|
||||
const lineCount = activeCode ? activeCode.split("\n").length : 0;
|
||||
const copyActive = () => {
|
||||
navigator.clipboard.writeText(activeCode).then(
|
||||
() => toast.success(i18n.t("runDetail.codeCopied")),
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex gap-1 p-2 border-b border-border/60">
|
||||
{files.map(([name]) => (
|
||||
<button key={name} onClick={() => setActive(name)} className={cn("px-3 py-1 rounded text-xs font-mono", active === name ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted/60")}>{name}</button>
|
||||
))}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-border/60">
|
||||
<div className="flex min-w-0 flex-wrap gap-1">
|
||||
{files.map(([name]) => (
|
||||
<button key={name} onClick={() => setActive(name)} className={cn("px-3 py-1 rounded text-xs font-mono", active === name ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted/60")}>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ms-auto flex shrink-0 items-center gap-2">
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{i18n.t("runDetail.codeLines", { count: lineCount })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyActive}
|
||||
className="flex items-center gap-1 rounded-md border border-border/60 px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-3 w-3" /> {i18n.t("runDetail.copyCode")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3 text-[11px] leading-relaxed bg-muted/20 [&_pre]:m-0 [&_pre]:bg-transparent [&_code]:text-[11px]">
|
||||
<div className="flex-1 overflow-auto p-3 text-xs leading-relaxed bg-muted/20 [&_pre]:m-0 [&_pre]:bg-transparent [&_code]:text-xs">
|
||||
<ReactMarkdown rehypePlugins={rehypePlugins}>
|
||||
{`\`\`\`python\n${code[active] || ""}\n\`\`\``}
|
||||
{`\`\`\`python\n${activeCode}\n\`\`\``}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -424,7 +424,9 @@ export function Settings() {
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">{t("settings.llmSettingsDesc")}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.8fr)]">
|
||||
{/* Column ratio matches the QVeris and data-source sections so the
|
||||
card seams align down the page. */}
|
||||
<form onSubmit={submit} className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(280px,0.9fr)]">
|
||||
<section className="rounded-lg border bg-card p-5 shadow-sm">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-primary" />
|
||||
@@ -509,7 +511,7 @@ export function Settings() {
|
||||
disabled={apiKeyDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className={hintClass}>{keyStatus}</span>
|
||||
{selectedProvider?.api_key_required ? (
|
||||
<label className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
@@ -634,7 +636,7 @@ export function Settings() {
|
||||
disabled={clearTushareToken}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className={hintClass}>
|
||||
{t("settings.tushareTokenDesc", {
|
||||
defaultValue: "Used for China A-share, futures, fund, and macro data. If unset, the project falls back to AKShare where available.",
|
||||
|
||||
Reference in new issue
Block a user