Chargement des corrélations...
;
if (error) return
Sélectionne tes idées de trade (2 à 10 paires). L'analyse te dit lesquelles sont des
setups distincts et lesquelles
sont redondantes (à arbitrer).
{sorted.map(p => {
const isSel = p.symbol === selected;
return (
onSelect(p.symbol)}
className={`w-full grid items-center gap-3 px-4 py-2.5 transition-colors text-left ${
isSel ? 'bg-gold/[0.06] border-l-2 border-gold-hi' : 'hover:bg-white/[0.02] border-l-2 border-transparent'
}`}
style={{gridTemplateColumns:'80px 1fr 80px'}}>
{p.symbol}
{p.long_pct}
/
{p.short_pct}
);
})}
);
}
function PositionsBlock({ pair }) {
if (!pair) return null;
const fmtPrice = v => v == null ? '—' : (v < 100 ? v.toFixed(4) : v.toFixed(2));
const fmtVol = v => {
if (v == null) return '—';
if (v >= 1_000_000) return `${(v/1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `${(v/1_000).toFixed(1)}K`;
return v.toFixed(0);
};
const fmtPos = v => v == null ? '—' : v.toLocaleString('fr-FR');
const stats = [
{ l:'Volume long', v: fmtVol(pair.long_volume), c:'#34d6a5'},
{ l:'Volume short', v: fmtVol(pair.short_volume), c:'#ff4757'},
{ l:'Positions long', v: fmtPos(pair.long_positions), c:'#34d6a5'},
{ l:'Positions short', v: fmtPos(pair.short_positions), c:'#ff4757'},
{ l:'Prix moy. long', v: fmtPrice(pair.avg_long_price), c:'#34d6a5'},
{ l:'Prix moy. short', v: fmtPrice(pair.avg_short_price), c:'#ff4757'},
];
return (
Chargement des discours et tweets...
;
if (error) return Chargement du sentiment retail...
;
if (error) return Aucune donnée disponible.
;
const pair = data.pairs.find(p => p.symbol === selected) || data.pairs[0];
return (
<>
= 0 ? '50%' : `${pct}%`,
width: score >= 0 ? `${pct - 50}%` : `${50 - pct}%`,
boxShadow: `0 0 8px ${color}`,
}}/>
{score > 0 ? `+${score}` : score}
);
}
function RankingTable({ ranking }) {
return (
#
Devise
Court terme
Long terme
Total
{ranking.map((r, i) => (
#{i + 1}
0 ? 'text-bull' : r.scoreTotal < 0 ? 'text-bear' : 'text-ink-mute'
}`}>{r.scoreTotal > 0 ? `+${r.scoreTotal}` : r.scoreTotal}
Drivers court terme
{r.driversShort?.length ? (
{r.driversShort.map((d, j) => (
{d}
))}
) :
Aucun signal CT
}
Drivers long terme
{r.driversLong?.length ? (
{r.driversLong.map((d, j) => (
{d}
))}
) :
Aucun signal LT
}
))}
);
}
function TradeIdeaCard({ idea }) {
const isLong = idea.direction === 'long';
const color = isLong ? '#34d6a5' : '#ff4757';
return (
{isLong ? '↑' : '↓'}
{idea.pair}
{idea.direction}
{idea.horizon}
{idea.rationale}
);
}
function TabDirection() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/direction')
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(d => { setData(d); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
}, []);
if (loading) return (
Agrégation de toutes les devises…
Premier calcul ~30s (8 devises × providers). Ensuite cache 15 min.
);
if (error) return
Erreur : {error}
;
if (!data) return
Aucune donnée.
;
return (
<>
Court terme
{(data.tradeIdeasShort || []).length === 0 ? (
Aucune idée CT (écarts trop faibles).
) : (
{data.tradeIdeasShort.map((idea, i) => )}
)}
Long terme
{(data.tradeIdeasLong || []).length === 0 ? (
Aucune idée LT (écarts trop faibles).
) : (
{data.tradeIdeasLong.map((idea, i) => )}
)}
{data.ranking?.length || 0} devises agrégées
>
);
}
/* ========== ECO ALERTS (push tel) ======================================== */
const eventClientKey = (e) => `${e.datetime_iso}|${e.country}|${e.title}`;
function useAlertsApi() {
const [alerts, setAlerts] = useState({}); // map keyed by eventClientKey
const [config, setConfig] = useState(null);
const reload = useCallback(async () => {
try {
const [aRes, cRes] = await Promise.all([
fetch('/api/alerts'),
fetch('/api/alerts/config'),
]);
const aData = aRes.ok ? await aRes.json() : { alerts: [] };
const cData = cRes.ok ? await cRes.json() : null;
const map = {};
for (const a of aData.alerts || []) {
map[`${a.datetime_iso}|${a.country}|${a.title}`] = a;
}
setAlerts(map);
if (cData) setConfig(cData);
} catch (e) {
console.warn('alerts reload failed', e);
}
}, []);
useEffect(() => { reload(); }, [reload]);
const upsert = async (payload) => {
const r = await fetch('/api/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
await reload();
};
const remove = async (id) => {
const r = await fetch(`/api/alerts/${id}`, { method: 'DELETE' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
await reload();
};
const saveConfig = async (payload) => {
const r = await fetch('/api/alerts/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const next = await r.json();
setConfig(next);
};
const testNotif = async () => {
const r = await fetch('/api/alerts/test', { method: 'POST' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
};
return { alerts, config, reload, upsert, remove, saveConfig, testNotif };
}
function BellIcon({ active }) {
return (
{active && }
);
}
function AlertPopover({ event, existing, config, onSave, onRemove, onClose }) {
const dflt = config || { default_lead_min: 5, default_channels: 'ntfy,telegram' };
const [lead, setLead] = useState(existing?.lead_min ?? dflt.default_lead_min);
const [channels, setChannels] = useState(() => new Set(
(existing?.channels || dflt.default_channels).split(',').map(s => s.trim()).filter(Boolean)
));
const [customMsg, setCustomMsg] = useState(existing?.custom_msg || '');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState(null);
const toggleChan = (c) => {
const n = new Set(channels);
if (n.has(c)) n.delete(c); else n.add(c);
setChannels(n);
};
const handleSave = async () => {
if (channels.size === 0) { setErr('Sélectionne au moins un canal'); return; }
setSaving(true); setErr(null);
try {
await onSave({
datetime_iso: event.datetime_iso,
title: event.title,
country: event.country,
impact: event.impact,
forecast: event.forecast || '',
previous: event.previous || '',
lead_min: Number(lead) || 5,
channels: [...channels].join(','),
custom_msg: customMsg.trim(),
enabled: true,
});
onClose();
} catch (e) {
setErr(e.message);
} finally {
setSaving(false);
}
};
const handleRemove = async () => {
if (!existing) return;
setSaving(true);
try { await onRemove(existing.id); onClose(); }
catch (e) { setErr(e.message); }
finally { setSaving(false); }
};
return (
e.stopPropagation()}>
{event.title}
{event.country} · {event.impact} · {new Date(event.datetime_iso).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })}
Canaux
{[
{ k: 'ntfy', label: 'ntfy.sh' },
{ k: 'telegram', label: 'Telegram' },
].map(({ k, label }) => {
const on = channels.has(k);
return (
toggleChan(k)}
className={`flex-1 px-3 py-2 text-[0.72rem] tracking-[0.18em] uppercase border transition-colors ${
on
? 'border-gold-hi text-gold-hi font-semibold bg-gold/[0.16]'
: 'border-gold/40 text-ink font-medium hover:border-gold/70 hover:text-gold-hi'
}`}>
{label}
);
})}
Message custom (vide = template par défaut)
{err &&
{err}
}
{existing && (
Supprimer
)}
Annuler
{saving ? '...' : (existing ? 'Mettre à jour' : 'Activer')}
);
}
function AlertsConfigPanel({ config, onSave, onTest, onClose }) {
const [form, setForm] = useState(() => ({
ntfy_topic: config?.ntfy_topic || '',
ntfy_server: config?.ntfy_server || 'https://ntfy.sh',
telegram_token: config?.telegram_token || '',
telegram_chat_id: config?.telegram_chat_id || '',
default_lead_min: config?.default_lead_min || 5,
default_channels: config?.default_channels || 'ntfy,telegram',
default_msg_template: config?.default_msg_template || '',
}));
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [msg, setMsg] = useState(null);
const upd = (k, v) => setForm(f => ({ ...f, [k]: v }));
const handleSave = async () => {
setSaving(true); setMsg(null);
try { await onSave(form); setMsg({ ok: true, txt: 'Config enregistrée.' }); }
catch (e) { setMsg({ ok: false, txt: e.message }); }
finally { setSaving(false); }
};
const handleTest = async () => {
setTesting(true); setMsg(null);
try {
const r = await onTest();
const lines = Object.entries(r.results || {})
.map(([k, v]) => `${k}: ${v.ok ? 'OK' : 'ERR'} (${v.info})`)
.join(' · ');
setMsg({ ok: true, txt: `Test envoyé · ${lines}` });
} catch (e) { setMsg({ ok: false, txt: e.message }); }
finally { setTesting(false); }
};
return (
e.stopPropagation()}>
Paramètres alertes push
Stocké en local (SQLite). Aucune donnée n'est envoyée à un tiers sans ton accord.
×
Défauts
Template du message
{msg && (
{msg.txt}
)}
{testing ? '...' : 'Envoyer un test'}
Fermer
{saving ? '...' : 'Enregistrer'}
);
}
const WEEKDAYS_FR = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'];
function buildTradingViewSnippet(days) {
const byWeekday = {};
for (const w of WEEKDAYS_FR) byWeekday[w] = [];
for (const day of days) {
const d = new Date(day.date + 'T12:00:00');
const idx = (d.getDay() + 6) % 7; // 0 = Lundi
if (idx > 4) continue; // ignore week-end
const wd = WEEKDAYS_FR[idx];
for (const ev of day.events) {
const hh = (ev.time || '').replace(':', 'h');
if (hh) byWeekday[wd].push(hh);
}
}
const lines = ['Annonce économique'];
for (const wd of WEEKDAYS_FR) {
const uniq = [...new Set(byWeekday[wd])].sort();
lines.push(uniq.length ? `${wd} : ${uniq.join(' · ')}` : `${wd} :`);
}
return lines.join('\n');
}
function TabCalendar() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selected, setSelected] = useState(() => new Set(CAL_COUNTRIES));
const [impacts, setImpacts] = useState(() => new Set(['Medium', 'High'])); // multi : Low / Medium / High
const [nextWeek, setNextWeek] = useState(false);
const [search, setSearch] = useState('');
const [popoverEvent, setPopoverEvent] = useState(null);
const [configOpen, setConfigOpen] = useState(false);
const [copied, setCopied] = useState(false);
const alertsApi = useAlertsApi();
const load = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
countries: CAL_COUNTRIES.join(','),
min_impact: 'Low',
});
if (nextWeek) params.set('next_week', 'true');
const r = await fetch(`/api/calendar?${params}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
setData(d);
setError(null);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, [nextWeek]);
useEffect(() => { load(); }, [load]);
const toggleCountry = (c) => {
const next = new Set(selected);
if (next.has(c)) next.delete(c); else next.add(c);
if (next.size === 0) return; // garde au moins 1
setSelected(next);
};
const allOn = selected.size === CAL_COUNTRIES.length;
const setAll = () => setSelected(new Set(CAL_COUNTRIES));
const setOnly = (c) => setSelected(new Set([c]));
const filtered = useMemo(() => {
if (!data?.days) return { days: [], total: 0, high: 0 };
const q = search.trim().toLowerCase();
const days = [];
let total = 0, high = 0;
for (const day of data.days) {
const evs = day.events.filter(e =>
selected.has(e.country) &&
impacts.has(e.impact) &&
(!q || e.title.toLowerCase().includes(q))
);
if (!evs.length) continue;
const dayHigh = evs.filter(e => e.impact === 'High').length;
total += evs.length;
high += dayHigh;
days.push({ ...day, events: evs, high_count: dayHigh, total: evs.length });
}
return { days, total, high };
}, [data, selected, impacts, search]);
const upcoming = useMemo(() => {
if (!data?.days) return [];
const now = Date.now();
const all = [];
for (const d of data.days) for (const e of d.events) {
if (e.impact !== 'High') continue;
if (!selected.has(e.country)) continue;
const t = new Date(e.datetime_iso).getTime();
if (t < now) continue;
const diffMs = t - now;
const h = diffMs / 3.6e6;
const human = h < 1
? `${Math.max(1, Math.round(diffMs / 6e4))} min`
: h < 24 ? `${Math.round(h)} h`
: `${Math.round(h / 24)} j`;
all.push({ ...e, in_human: human, _t: t });
}
all.sort((a, b) => a._t - b._t);
return all.slice(0, 5);
}, [data, selected]);
if (loading) return
Chargement du calendrier...
;
if (error) return
Erreur : {error}
;
if (!data) return
Aucune donnée.
;
const weekRange = data.week_start && data.week_end
? `${new Date(data.week_start).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })} → ${new Date(data.week_end).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })}`
: '—';
return (
<>
{/* HEADER FILTERS */}
Devises
Toutes
{CAL_COUNTRIES.map(c => {
const cnt = data.per_country?.[c]?.total ?? 0;
return (
);
})}
Impact
{[
{ key: 'High', label: 'High', color: IMPACT_COLOR.High },
{ key: 'Medium', label: 'Medium', color: IMPACT_COLOR.Medium },
{ key: 'Low', label: 'Low', color: IMPACT_COLOR.Low },
].map(({ key, label, color }) => {
const active = impacts.has(key);
return (
{
const next = new Set(impacts);
if (next.has(key)) next.delete(key); else next.add(key);
if (next.size === 0) return; // garde au moins 1
setImpacts(next);
}}
className={`flex items-center gap-2 text-[0.6rem] tracking-[0.22em] uppercase px-3 py-1.5 border transition-colors ${
active
? 'border-gold-hi text-gold-hi font-semibold bg-gold/[0.16]'
: 'border-gold/40 text-ink font-medium hover:border-gold/70 hover:text-gold-hi'
}`}>
{label}
);
})}
setNextWeek(v => !v)}
className={`text-[0.6rem] tracking-[0.22em] uppercase px-3 py-1.5 border transition-colors ${
nextWeek
? 'border-gold-hi text-gold-hi font-semibold bg-gold/[0.16]'
: 'border-gold/40 text-ink font-medium hover:border-gold/70 hover:text-gold-hi'
}`}>
+ Semaine prochaine
{
if (selected.size !== 2) return;
const txt = buildTradingViewSnippet(filtered.days);
try { await navigator.clipboard.writeText(txt); }
catch { /* clipboard indisponible */ }
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}}
disabled={selected.size !== 2}
title={selected.size === 2
? `Copier l'encadré ${[...selected].sort().join('/')} pour TradingView`
: 'Sélectionner exactement 2 devises pour générer l\'encadré'}
className={`flex items-center gap-1.5 text-[0.6rem] tracking-[0.22em] uppercase px-3 py-1.5 border transition-colors ${
selected.size !== 2
? 'border-gold/30 text-ink-soft cursor-not-allowed'
: copied
? 'border-gold-hi text-gold-hi font-semibold bg-gold/[0.18]'
: 'border-gold/45 text-ink font-medium hover:bg-gold/[0.08]'
}`}>
{copied ? 'Copié ✓' : 'Copier TradingView'}
setConfigOpen(true)}
title="Configurer les notifications push (ntfy + Telegram)"
className="flex items-center gap-1.5 text-[0.6rem] tracking-[0.22em] uppercase px-3 py-1.5 border border-gold/45 text-ink font-medium hover:bg-gold/[0.08]">
Alertes
{Object.keys(alertsApi.alerts).length > 0 && (
{Object.values(alertsApi.alerts).filter(a => a.enabled && !a.sent_at).length}
)}
setSearch(e.target.value)}
placeholder="Rechercher un événement..."
className="bg-transparent border border-gold/25 text-ink text-[0.78rem] px-3 py-1.5 w-64 focus:outline-none focus:border-gold/40 placeholder:text-ink-soft/70"/>
{filtered.total} affichés · MaJ {data.fetched_at}
{/* UPCOMING */}
{/* DAYS */}
{filtered.days.length === 0 ? (
Aucun événement ne correspond aux filtres.
) : (
{filtered.days.map(day => (
{day.label}
{day.events.length} évén. · {day.high_count} high
{day.events.map((e, i) => {
const eKey = eventClientKey(e);
const existing = alertsApi.alerts[eKey];
const future = new Date(e.datetime_iso).getTime() > Date.now();
const armed = existing && existing.enabled && !existing.sent_at;
return (
{e.time}
setOnly(e.country)} title={`Filtrer ${CAL_LABELS[e.country] || e.country}`}>
{e.title}
Préc.
{e.previous || '—'}
Prév.
{e.forecast || '—'}
Réel
{e.actual || '—'}
future && setPopoverEvent(e)}
disabled={!future}
title={future
? (armed ? `Alerte active · ${existing.lead_min} min avant` : 'Activer une notif push')
: 'Évènement passé'}
className={`flex items-center justify-center w-7 h-7 transition-colors ${
!future ? 'text-ink-mute/30 cursor-not-allowed' :
armed ? 'gold-text hover:text-gold-hi' :
'text-ink-mute hover:text-ink-soft'
}`}>
);
})}
))}
)}
Heures locales
{popoverEvent && (
setPopoverEvent(null)}
/>
)}
{configOpen && (
setConfigOpen(false)}
/>
)}
>
);
}
/* ========== TAB ENTREPRISE =============================================== */
const CUR_SYM = { USD: '$', EUR: '€', GBP: '£', JPY: '¥', CHF: 'CHF', HKD: 'HK$', CNY: '¥' };
const curSym = c => CUR_SYM[c] || (c ? c + ' ' : '$');
const fmtMoney = (v, cur) => v == null ? 'n/d' : `${curSym(cur)}${v}`;
const fmtMcap = (v, cur) => {
if (v == null) return 'n/d';
const s = curSym(cur);
if (v >= 1e12) return `${(v / 1e12).toFixed(2)} T${s}`;
if (v >= 1e9) return `${(v / 1e9).toFixed(0)} Md${s}`;
if (v >= 1e6) return `${(v / 1e6).toFixed(0)} M${s}`;
return `${s}${v}`;
};
const fmtEarnDate = iso => {
if (!iso) return 'n/d';
const d = new Date(iso + 'T00:00:00');
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' });
};
function SurpriseTag({ pct }) {
if (pct == null) return — ;
const up = pct >= 0;
return (
{up ? '▲' : '▼'} {up ? '+' : ''}{pct}%
);
}
function CountdownChip({ days }) {
if (days == null) return null;
const soon = days <= 7;
const cls = soon
? 'border-neut/50 text-neut bg-neut/[0.06]'
: 'border-gold/30 text-gold-hi bg-gold/[0.04]';
const label = days === 0 ? "Aujourd'hui" : days < 0 ? 'Passé' : `J-${days}`;
return (
{label}
);
}
function CompanyLogo({ domain, name, size = 38 }) {
const [ok, setOk] = useState(true);
const initial = (name || '?').trim().charAt(0).toUpperCase();
if (domain && ok) {
return (
setOk(false)}
className="rounded-full bg-white/[0.04] border border-gold/20 object-contain p-1 shrink-0"
style={{ width: size, height: size }}
/>
);
}
return (
{initial}
);
}
function CompanyCard({ c }) {
const ne = c.next_event;
const lr = c.last_result;
const cur = c.currency;
return (
{/* En-tête société */}
{c.name}
{c.ticker} · {c.sector}
{fmtMoney(c.price, cur)}
{fmtMcap(c.market_cap, cur)}
{/* Prochaine parution + anticipation */}
Prochaine parution
{ne && }
{ne ? (
{fmtEarnDate(ne.date)}
EPS anticipé {fmtMoney(ne.eps_estimate, cur)}
) : (
Date non publiée
)}
{/* Dernier résultat */}
Dernier résultat publié
{lr ? (
Date
{fmtEarnDate(lr.date)}
EPS réalisé
{fmtMoney(lr.reported_eps, cur)}
cons. {fmtMoney(lr.eps_estimate, cur)}
) : (
Aucun historique disponible
)}
{/* Historique compact */}
{c.history?.length > 1 && (
{c.history.slice(0, 4).map((h, i) => {
const up = h.surprise_pct != null && h.surprise_pct >= 0;
return (
);
})}
)}
);
}
function TabEntreprise() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/earnings')
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(d => { setData(d); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
}, []);
if (loading) return Chargement des résultats d'entreprises...
Première requête : ~5s.
;
if (error) return Erreur : {error}
;
if (!data?.companies?.length) return Aucune donnée disponible.
;
const upcoming = data.companies.filter(c => c.next_event && c.next_event.days_until <= 14);
return (
<>
{upcoming.length > 0 && (
{upcoming.map(c => (
{c.name}
{fmtEarnDate(c.next_event.date)}
EPS att. {fmtMoney(c.next_event.eps_estimate, c.currency)}
))}
)}
{data.companies.map(c => )}
EPS anticipé = consensus analystes
>
);
}
/* ========== TAB LOT SIZER ================================================ */
const ACCOUNT_SYMBOL = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', AUD: 'A$', CAD: 'C$', NZD: 'NZ$' };
const ACCOUNT_OPTIONS = [
{ value: 'EUR', label: 'EUR €' },
{ value: 'USD', label: 'USD $' },
{ value: 'GBP', label: 'GBP £' },
{ value: 'JPY', label: 'JPY ¥' },
{ value: 'CHF', label: 'CHF' },
{ value: 'AUD', label: 'AUD A$' },
{ value: 'CAD', label: 'CAD C$' },
{ value: 'NZD', label: 'NZD NZ$' },
];
function nfmt(n, d = 2) {
if (n === null || n === undefined || !isFinite(n)) return '—';
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: d, maximumFractionDigits: d }).format(n);
}
function floorTo(n, step) {
return Math.floor(n / step) * step;
}
function LotInput({ label, value, onChange, suffix, type = 'number', step, placeholder, hint, invalid }) {
return (
{label}
onChange(e.target.value)}
step={step} placeholder={placeholder}
className="w-full bg-transparent px-3 py-2.5 text-[0.95rem] text-ink-soft tabular-nums focus:outline-none placeholder:text-ink-mute/50"/>
{suffix && {suffix} }
{hint && {hint}
}
);
}
function LotSegmented({ label, options, value, onChange }) {
return (
{label}
{options.map((o, i) => (
onChange(o.value)}
className={`flex-1 px-3 py-2.5 text-[0.7rem] tracking-[0.2em] uppercase border transition-colors ${
value === o.value
? 'border-gold-hi text-gold-hi font-semibold bg-gold/[0.16]'
: 'border-gold/40 text-ink font-medium hover:border-gold/70 hover:text-gold-hi'
} ${i > 0 ? '-ml-px' : ''}`}>
{o.label}
))}
);
}
function StepRow({ label, value, sub, accent }) {
return (
);
}
function LotResultCard({ title, lots, risk, currency, hint, primary }) {
const lotsValid = lots !== null && isFinite(lots) && lots > 0;
return (
{title}
{lotsValid ? nfmt(lots, primary ? 3 : 2) : '—'}
lots
Risque effectif : {nfmt(risk, 2)} {currency}
{hint &&
{hint}
}
);
}
function TabLotSizer() {
const [setup, setSetup] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Inputs
const [account, setAccount] = useState('EUR');
const [balance, setBalance] = useState('80000');
const [riskMode, setRiskMode] = useState('pct'); // pct | cash
const [riskValue, setRiskValue] = useState('1');
const [symbol, setSymbol] = useState('EURUSD');
const [entry, setEntry] = useState('');
const [slPips, setSlPips] = useState('30');
const [side, setSide] = useState('long'); // long | short
const loadSetup = useCallback(async (acc) => {
setLoading(true);
try {
const r = await fetch(`/api/lot/setup?account=${acc}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
setSetup(d);
setError(null);
} catch (e) { setError(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { loadSetup(account); }, [account, loadSetup]);
// Auto-fill entry quand on change de symbol (avec le live price)
useEffect(() => {
if (setup?.prices && setup.prices[symbol] != null) {
setEntry(String(setup.prices[symbol]));
}
}, [symbol, setup]);
if (loading) return Chargement des taux live...
;
if (error) return Erreur : {error}
;
if (!setup) return null;
const ins = setup.instruments.find(i => i.symbol === symbol);
const ccySymbol = ACCOUNT_SYMBOL[account] || account;
// ---- MATH (reactive) -------------------------------------------------
const balanceN = parseFloat(balance);
const riskValueN = parseFloat(riskValue);
const slPipsN = parseFloat(slPips);
const entryN = parseFloat(entry);
const riskCash = riskMode === 'pct'
? (isFinite(balanceN) && isFinite(riskValueN) ? balanceN * riskValueN / 100 : NaN)
: (isFinite(riskValueN) ? riskValueN : NaN);
const quoteCcy = ins.quote;
const rate = setup.rates[quoteCcy]; // quote -> account
const pipValueQuote = ins.pip_value_quote; // par pip par lot, en quote
const pipValueAccount = (rate != null) ? pipValueQuote * rate : null;
const denom = (slPipsN > 0 && pipValueAccount > 0) ? slPipsN * pipValueAccount : 0;
const lotsRaw = (riskCash > 0 && denom > 0) ? riskCash / denom : null;
const lot001 = lotsRaw ? floorTo(lotsRaw, 0.01) : null;
const lot01 = lotsRaw ? floorTo(lotsRaw, 0.10) : null;
const riskAt = (l) => (l != null && pipValueAccount && slPipsN > 0) ? l * slPipsN * pipValueAccount : null;
const risk001 = riskAt(lot001);
const risk01 = riskAt(lot01);
// SL price (long: entry - sl*pip ; short: entry + sl*pip)
const slPrice = (isFinite(entryN) && slPipsN > 0)
? (side === 'long' ? entryN - slPipsN * ins.pip_size : entryN + slPipsN * ins.pip_size)
: null;
// Notional (taille de position en devise de compte) sur entry
const notional = (lot001 && isFinite(entryN))
? lot001 * ins.lot_units * entryN * (setup.rates[quoteCcy] ?? 1)
: null;
// ---- WARNINGS --------------------------------------------------------
const warnings = [];
const inputsInvalid = !(balanceN > 0) || !(riskValueN > 0) || !(slPipsN > 0) || !isFinite(entryN);
if (riskMode === 'pct' && riskValueN > 5) warnings.push('Risque > 5 % du compte — agressif.');
if (riskMode === 'cash' && balanceN > 0 && riskValueN / balanceN > 0.05)
warnings.push(`Risque > 5 % du compte (${nfmt(riskValueN/balanceN*100, 1)} %).`);
if (lotsRaw != null && lot001 < 0.01) warnings.push('Lot calculé < 0.01 — sous le minimum brokers FX.');
if (rate == null) warnings.push(`Taux ${quoteCcy}→${account} indisponible — résultat impossible.`);
return (
<>
{/* ===== INPUTS ===== */}
Paramètres du trade
Devise du compte
setAccount(e.target.value)}
className="w-full bg-gold/[0.02] border border-gold/20 px-3 py-2.5 text-[0.92rem] text-ink-soft focus:outline-none focus:border-gold/50 appearance-none cursor-pointer">
{ACCOUNT_OPTIONS.map(o => (
{o.label}
))}
0)}/>
0)}
hint={riskMode === 'pct' && balanceN > 0 && riskValueN > 0
? `≈ ${nfmt(balanceN * riskValueN / 100, 2)} ${ccySymbol}` : null}/>
Instrument
setSymbol(e.target.value)}
className="w-full bg-gold/[0.02] border border-gold/20 px-3 py-2.5 text-[0.92rem] text-ink-soft focus:outline-none focus:border-gold/50 appearance-none cursor-pointer">
{setup.instruments.filter(i => i.kind === 'fx').map(i => (
{i.label}
))}
{setup.instruments.filter(i => i.kind === 'index').map(i => (
{i.symbol} · {i.label}
))}
{setup.instruments.filter(i => i.kind === 'crypto').map(i => (
{i.symbol} · {i.label}
))}
Pip = {ins.pip_size} · 1 lot = {ins.pip_value_quote} {ins.quote}/pip
{(ins.kind === 'index' || ins.kind === 'crypto') && ' · spec CFD IC Markets / Pepperstone'}
0)}
hint={slPrice ? `SL prix ≈ ${nfmt(slPrice, ins.kind === 'fx' && ins.quote !== 'JPY' ? 5 : ins.quote === 'JPY' ? 3 : 2)}` : null}/>
{warnings.length > 0 && (
{warnings.map((w, i) => (
› {w}
))}
)}
{/* ===== RESULT ===== */}
Détail du calcul
0 ? `${nfmt(denom, 2)} ${ccySymbol}` : '—'}
sub={`SL ${slPipsN || '?'} pips × pip value`}/>
Arrondi systématique vers le bas · Vérifie les contract specs avec ton broker
>
);
}
/* ========== APP ========================================================== */
function App() {
const [state, setState] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [refreshing, setRefreshing] = useState(false);
const [view, setView] = useState(VIEW_PRINCIPAL); // 'PRINCIPALE' | 'USD' | 'EUR' | ...
const [tabPrincipal, setTabPrincipal] = useState('Direction du marché');
const [tabCountry, setTabCountry] = useState('Général');
const isCountryView = SUPPORTED_COUNTRIES.includes(view);
const country = isCountryView ? view : null;
const load = useCallback(async () => {
if (!isCountryView) { setState(null); return; }
setLoading(true);
try {
const r = await fetch(`/api/state?country=${country}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
setState(d);
setError(null);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, [isCountryView, country]);
useEffect(() => { load(); }, [load]);
const onRefresh = async () => {
if (!isCountryView) return;
setRefreshing(true);
try {
const r = await fetch(`/api/refresh?country=${country}`, { method: 'POST' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
setState(d);
} catch (e) {
setError(e.message);
} finally {
setRefreshing(false);
}
};
if (loading) return (
FondAnalyse
Chargement des données...
);
if (error) return (
Erreur de chargement
{error}
Réessayer
);
// ========== VUE PRINCIPALE ==========
if (!isCountryView) {
return (
<>