feat: causal lab - delete template + observable mapping + edge editing
- TabLibrary: bouton Supprimer (templates user uniquement) - TabEditor: inputMapping dans EditorState, panneau mapping visible quand noeud observable selectionne - TabEditor: edition d aretes (cliquer sur arête pour modifier style/force/signe/label) - Backend: GET /api/causal-lab/data-sources (prices/macro_series/ff_events) - analyze: auto-fetch market_watchlist+economic_events+ff_calendar depuis input_mapping - TabAnalyze: affiche inputs auto-recuperes vs manuels dans les resultats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -500,6 +500,69 @@ def update_template(template_id: int, body: UpdateCoefRequest):
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/api/causal-lab/data-sources")
|
||||
def get_data_sources():
|
||||
"""
|
||||
Retourne toutes les sources de données disponibles pour le mapping des nœuds observables :
|
||||
- prices : tickers yfinance (market_watchlist + defaults)
|
||||
- macro_series : series_id distincts depuis economic_events
|
||||
- ff_events : événements récurrents depuis ff_calendar
|
||||
"""
|
||||
PRICE_DEFAULTS = [
|
||||
{"key": "EURUSD", "label": "EUR/USD"},
|
||||
{"key": "XAUUSD", "label": "Or (Gold)"},
|
||||
{"key": "SP500", "label": "S&P 500"},
|
||||
{"key": "BRENT", "label": "Brent Crude"},
|
||||
{"key": "US2Y", "label": "US 2Y Yield"},
|
||||
{"key": "US10Y", "label": "US 10Y Yield"},
|
||||
{"key": "EU10Y", "label": "EU 10Y Yield"},
|
||||
]
|
||||
try:
|
||||
from services.database import get_conn
|
||||
conn = get_conn()
|
||||
|
||||
wl = conn.execute(
|
||||
"SELECT ticker, name FROM market_watchlist ORDER BY ticker"
|
||||
).fetchall()
|
||||
known = {x["key"] for x in PRICE_DEFAULTS}
|
||||
extra = [
|
||||
{"key": r["ticker"], "label": r["name"] or r["ticker"]}
|
||||
for r in wl if r["ticker"] not in known
|
||||
]
|
||||
|
||||
macro = conn.execute(
|
||||
"""SELECT DISTINCT series_id, event_name
|
||||
FROM economic_events
|
||||
WHERE series_id IS NOT NULL AND series_id != ''
|
||||
ORDER BY series_id LIMIT 200"""
|
||||
).fetchall()
|
||||
|
||||
ff = conn.execute(
|
||||
"""SELECT event_name, currency, COUNT(*) as cnt
|
||||
FROM ff_calendar
|
||||
WHERE event_name IS NOT NULL AND event_name != ''
|
||||
GROUP BY event_name, currency
|
||||
HAVING cnt >= 2
|
||||
ORDER BY cnt DESC LIMIT 100"""
|
||||
).fetchall()
|
||||
|
||||
conn.close()
|
||||
return {
|
||||
"prices": PRICE_DEFAULTS + extra,
|
||||
"macro_series": [
|
||||
{"key": r["series_id"], "label": r["event_name"] or r["series_id"]}
|
||||
for r in macro
|
||||
],
|
||||
"ff_events": [
|
||||
{"key": r["event_name"], "label": f"{r['event_name']} ({r['currency']})"}
|
||||
for r in ff
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[causal_lab] data_sources: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/api/causal-lab/market-events")
|
||||
def list_market_events(
|
||||
limit: int = Query(200, le=500),
|
||||
@@ -604,21 +667,94 @@ def analyze_event(body: AnalyzeRequest):
|
||||
|
||||
graph = tmpl["graph_json"]
|
||||
|
||||
# Inputs de base depuis l'événement
|
||||
# Inputs de base depuis l'événement + mappings DB/yfinance
|
||||
inputs = {}
|
||||
mapping = graph.get("input_mapping", {})
|
||||
YFINANCE_MAP = {
|
||||
"EURUSD": "EURUSD=X", "XAUUSD": "GC=F", "SP500": "^GSPC",
|
||||
"BRENT": "BZ=F", "US2Y": "US2YT=RR", "US10Y": "^TNX", "EU10Y": "GE10YT=RR",
|
||||
}
|
||||
edate_str = event["start_date"][:10]
|
||||
for input_id, cfg in mapping.items():
|
||||
src = cfg.get("source", "")
|
||||
if src == "surprise" and event.get("surprise_pct") is not None:
|
||||
src = cfg.get("source", "")
|
||||
key = cfg.get("key", "")
|
||||
field = cfg.get("field", "close")
|
||||
|
||||
if src == "market_watchlist" and key:
|
||||
try:
|
||||
import yfinance as yf
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
event_dt = _dt.strptime(edate_str, "%Y-%m-%d")
|
||||
start = (event_dt - _td(days=5)).strftime("%Y-%m-%d")
|
||||
end = (event_dt + _td(days=2)).strftime("%Y-%m-%d")
|
||||
sym = YFINANCE_MAP.get(key, key)
|
||||
df = yf.download(sym, start=start, end=end,
|
||||
interval="1d", progress=False, auto_adjust=True)
|
||||
if df is not None and len(df) > 0:
|
||||
if hasattr(df.columns, "levels"):
|
||||
df.columns = df.columns.get_level_values(0)
|
||||
rows_yf = [
|
||||
(str(idx.date()), float(row["Close"]))
|
||||
for idx, row in df.iterrows()
|
||||
if float(row["Close"]) == float(row["Close"])
|
||||
]
|
||||
if field == "change_pct" and len(rows_yf) >= 2:
|
||||
pre = next((c for d, c in rows_yf if d < edate_str), None)
|
||||
cur = next((c for d, c in rows_yf if d == edate_str), None)
|
||||
if pre and cur and pre != 0:
|
||||
inputs[input_id] = round((cur - pre) / pre * 100, 4)
|
||||
else:
|
||||
val = next((c for d, c in rows_yf if d == edate_str), None)
|
||||
if val is None:
|
||||
val = next((c for d, c in reversed(rows_yf) if d <= edate_str), None)
|
||||
if val is not None:
|
||||
inputs[input_id] = round(val, 5)
|
||||
except Exception as _e:
|
||||
logger.debug(f"[causal_lab] market_watchlist fetch {key}: {_e}")
|
||||
|
||||
elif src == "economic_events" and key:
|
||||
row_e = conn.execute(
|
||||
"""SELECT actual_value, forecast_value FROM economic_events
|
||||
WHERE series_id = ? AND event_date <= ? AND actual_value IS NOT NULL
|
||||
ORDER BY event_date DESC LIMIT 1""",
|
||||
(key, edate_str)
|
||||
).fetchone()
|
||||
if row_e:
|
||||
if field == "surprise" and row_e["forecast_value"] is not None:
|
||||
inputs[input_id] = round(
|
||||
float(row_e["actual_value"]) - float(row_e["forecast_value"]), 4
|
||||
)
|
||||
else:
|
||||
inputs[input_id] = float(row_e["actual_value"])
|
||||
|
||||
elif src == "ff_calendar" and key:
|
||||
row_f = conn.execute(
|
||||
"""SELECT actual_value, forecast_value FROM ff_calendar
|
||||
WHERE event_name = ? AND event_date <= ? AND actual_value IS NOT NULL
|
||||
ORDER BY event_date DESC LIMIT 1""",
|
||||
(key, edate_str)
|
||||
).fetchone()
|
||||
if row_f and row_f["actual_value"]:
|
||||
try:
|
||||
actual = float(row_f["actual_value"])
|
||||
if field == "surprise" and row_f["forecast_value"]:
|
||||
inputs[input_id] = round(actual - float(row_f["forecast_value"]), 4)
|
||||
else:
|
||||
inputs[input_id] = actual
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Sources legacy
|
||||
elif src == "surprise" and event.get("surprise_pct") is not None:
|
||||
inputs[input_id] = float(event["surprise_pct"])
|
||||
elif src == "impact_score_scaled" and event.get("impact_score") is not None:
|
||||
inputs[input_id] = float(event["impact_score"]) / 10.0
|
||||
elif src == "impact_score_pct" and event.get("impact_score") is not None:
|
||||
inputs[input_id] = float(event["impact_score"])
|
||||
elif src == "actual_value" and event.get("actual_value") is not None:
|
||||
elif src == "actual_value" and event.get("actual_value") is not None:
|
||||
inputs[input_id] = float(event["actual_value"])
|
||||
|
||||
# Inputs manuels (ex: tone_score depuis le frontend)
|
||||
# Inputs manuels (saisie frontend — priorité sur auto-fetch)
|
||||
inputs.update(body.inputs)
|
||||
|
||||
# Évaluation du graphe
|
||||
|
||||
@@ -23,11 +23,23 @@ export interface CausalEdge {
|
||||
label?: string
|
||||
}
|
||||
interface Coefficient { value: number; calibrated: number | null; description: string }
|
||||
interface InputMapping {
|
||||
source: 'user_input' | 'market_watchlist' | 'economic_events' | 'ff_calendar' | 'surprise' | 'actual_value' | 'impact_score_scaled' | 'impact_score_pct'
|
||||
key?: string
|
||||
field?: string
|
||||
unit?: string
|
||||
range?: number[]
|
||||
}
|
||||
interface DataSources {
|
||||
prices: { key: string; label: string }[]
|
||||
macro_series: { key: string; label: string }[]
|
||||
ff_events: { key: string; label: string }[]
|
||||
}
|
||||
export interface GraphJson {
|
||||
nodes: CausalNode[]; edges: CausalEdge[]
|
||||
coefficients: Record<string, Coefficient>
|
||||
instruments: string[]
|
||||
input_mapping: Record<string, { source: string; unit?: string; range?: number[] }>
|
||||
input_mapping: Record<string, InputMapping>
|
||||
}
|
||||
interface Template {
|
||||
id: number; name: string; category: string; sub_type: string
|
||||
@@ -324,6 +336,7 @@ function TabLibrary() {
|
||||
const [selected, setSelected] = useState<Template | null>(null)
|
||||
const [catFilter, setCatFilter] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [editCoefs, setEditCoefs] = useState<Record<string, number>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [apiError, setApiError] = useState<string | null>(null)
|
||||
@@ -360,6 +373,20 @@ function TabLibrary() {
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function deleteTemplate() {
|
||||
if (!selected) return
|
||||
if (!window.confirm(`Supprimer "${selected.name}" ? Cette action est irréversible.`)) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api(`/api/causal-lab/template/${selected.id}`, { method: 'DELETE' })
|
||||
setSelected(null)
|
||||
const data = await api(`/api/causal-lab/templates${catFilter ? `?category=${catFilter}` : ''}`)
|
||||
setTemplates(data)
|
||||
} catch (e: unknown) {
|
||||
alert(String(e))
|
||||
} finally { setDeleting(false) }
|
||||
}
|
||||
|
||||
const cats = [...new Set(templates.map(t => t.category))]
|
||||
|
||||
return (
|
||||
@@ -411,9 +438,18 @@ function TabLibrary() {
|
||||
<h3 className="text-white font-semibold">{selected.name}</h3>
|
||||
<p className="text-slate-400 text-xs mt-1">{selected.description}</p>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-1 rounded bg-purple-900/30 text-purple-300 border border-purple-700/40">
|
||||
v{selected.heuristic_ver}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs px-2 py-1 rounded bg-purple-900/30 text-purple-300 border border-purple-700/40">
|
||||
v{selected.heuristic_ver}
|
||||
</span>
|
||||
{selected.created_by !== 'system' && (
|
||||
<button onClick={deleteTemplate} disabled={deleting}
|
||||
title="Supprimer ce template"
|
||||
className="p-1 rounded hover:bg-red-900/30 text-slate-500 hover:text-red-400 transition-colors disabled:opacity-40">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selected.ai_rationale && (
|
||||
<div className="mt-3 p-3 bg-blue-900/10 border border-blue-800/30 rounded text-xs text-blue-200">
|
||||
@@ -476,12 +512,13 @@ interface EditorState {
|
||||
description: string; instruments: string[]; aiRationale: string
|
||||
nodes: CausalNode[]; edges: CausalEdge[]
|
||||
coefficients: Record<string, Coefficient>
|
||||
inputMapping: Record<string, InputMapping>
|
||||
}
|
||||
|
||||
const BLANK: EditorState = {
|
||||
templateId: null, name: 'Nouveau template', category: 'macro_us', subType: '',
|
||||
description: '', instruments: ['EURUSD'], aiRationale: '',
|
||||
nodes: [], edges: [], coefficients: {},
|
||||
nodes: [], edges: [], coefficients: {}, inputMapping: {},
|
||||
}
|
||||
|
||||
const INSTRUMENTS_LIST = ['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US2Y', 'US10Y']
|
||||
@@ -489,28 +526,33 @@ const INSTRUMENTS_LIST = ['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US2Y', 'US10Y']
|
||||
function TabEditor() {
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [state, setState] = useState<EditorState>(BLANK)
|
||||
const [selNode, setSelNode] = useState<string | null>(null) // id du nœud sélectionné pour repositionnement
|
||||
const [selNode, setSelNode] = useState<string | null>(null)
|
||||
const [editEdgeIdx, setEditEdge] = useState<number | null>(null)
|
||||
const [addNodeOpen, setAddNode] = useState(false)
|
||||
const [addEdgeOpen, setAddEdge] = useState(false)
|
||||
const [newNode, setNewNode] = useState<Partial<CausalNode>>({ type: 'observable', x: 200, y: 200 })
|
||||
const [newEdge, setNewEdge] = useState<Partial<CausalEdge>>({ style: 'solid', strength: 2, sign: 'positive' })
|
||||
const [editEdge, setEditEdgeVal] = useState<Partial<CausalEdge>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const [dataSources, setDS] = useState<DataSources>({ prices: [], macro_series: [], ff_events: [] })
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api('/api/causal-lab/templates').then(setTemplates)
|
||||
api('/api/causal-lab/data-sources').then(setDS).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function loadTemplate(id: number) {
|
||||
const t = templates.find(x => x.id === id)
|
||||
if (!t) return
|
||||
setState({
|
||||
templateId: t.id, name: t.name + ' (copie)', category: t.category, subType: t.sub_type,
|
||||
templateId: t.id, name: t.name, category: t.category, subType: t.sub_type,
|
||||
description: t.description, instruments: t.instruments, aiRationale: t.ai_rationale,
|
||||
nodes: JSON.parse(JSON.stringify(t.graph_json.nodes || [])),
|
||||
edges: JSON.parse(JSON.stringify(t.graph_json.edges || [])),
|
||||
coefficients: JSON.parse(JSON.stringify(t.graph_json.coefficients || {})),
|
||||
inputMapping: JSON.parse(JSON.stringify(t.graph_json.input_mapping || {})),
|
||||
})
|
||||
setSelNode(null)
|
||||
}
|
||||
@@ -520,7 +562,8 @@ function TabEditor() {
|
||||
const graphJson: GraphJson = {
|
||||
nodes: state.nodes, edges: state.edges,
|
||||
coefficients: state.coefficients,
|
||||
instruments: state.instruments, input_mapping: {},
|
||||
instruments: state.instruments,
|
||||
input_mapping: state.inputMapping,
|
||||
}
|
||||
|
||||
// Click SVG background → reposition selected node
|
||||
@@ -557,11 +600,15 @@ function TabEditor() {
|
||||
}
|
||||
|
||||
function removeNode(id: string) {
|
||||
setState(s => ({
|
||||
...s,
|
||||
nodes: s.nodes.filter(n => n.id !== id),
|
||||
edges: s.edges.filter(e => e.from !== id && e.to !== id),
|
||||
}))
|
||||
setState(s => {
|
||||
const im = { ...s.inputMapping }; delete im[id]
|
||||
return {
|
||||
...s,
|
||||
nodes: s.nodes.filter(n => n.id !== id),
|
||||
edges: s.edges.filter(e => e.from !== id && e.to !== id),
|
||||
inputMapping: im,
|
||||
}
|
||||
})
|
||||
if (selNode === id) setSelNode(null)
|
||||
}
|
||||
|
||||
@@ -581,6 +628,22 @@ function TabEditor() {
|
||||
|
||||
function removeEdge(i: number) {
|
||||
setState(s => ({ ...s, edges: s.edges.filter((_, idx) => idx !== i) }))
|
||||
if (editEdgeIdx === i) setEditEdge(null)
|
||||
}
|
||||
|
||||
function startEditEdge(i: number) {
|
||||
const e = state.edges[i]
|
||||
setEditEdgeVal({ ...e })
|
||||
setEditEdge(i)
|
||||
}
|
||||
|
||||
function applyEdgeEdit() {
|
||||
if (editEdgeIdx === null) return
|
||||
setState(s => ({
|
||||
...s,
|
||||
edges: s.edges.map((e, i) => i === editEdgeIdx ? { ...e, ...editEdge } : e),
|
||||
}))
|
||||
setEditEdge(null)
|
||||
}
|
||||
|
||||
function autoDetectCoefs(nodes: CausalNode[], existing: Record<string, Coefficient>) {
|
||||
@@ -598,7 +661,7 @@ function TabEditor() {
|
||||
const body = {
|
||||
name: state.name, category: state.category, sub_type: state.subType,
|
||||
description: state.description, instruments: state.instruments,
|
||||
graph_json: { ...graphJson, input_mapping: {} },
|
||||
graph_json: graphJson,
|
||||
ai_rationale: state.aiRationale,
|
||||
}
|
||||
const r = await api('/api/causal-lab/template', {
|
||||
@@ -622,7 +685,7 @@ function TabEditor() {
|
||||
body: JSON.stringify({
|
||||
name: state.name, category: state.category, sub_type: state.subType,
|
||||
description: state.description, instruments: state.instruments,
|
||||
graph_json: { ...graphJson, input_mapping: {} },
|
||||
graph_json: graphJson,
|
||||
ai_rationale: state.aiRationale,
|
||||
}),
|
||||
})
|
||||
@@ -634,6 +697,7 @@ function TabEditor() {
|
||||
}
|
||||
|
||||
const nodeIds = state.nodes.map(n => n.id)
|
||||
const selectedNodeObj = selNode ? state.nodes.find(n => n.id === selNode) : null
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
@@ -801,6 +865,75 @@ function TabEditor() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mapping observable */}
|
||||
{selectedNodeObj?.type === 'observable' && selNode && (() => {
|
||||
const mapping: InputMapping = state.inputMapping[selNode] || { source: 'user_input' }
|
||||
const updateMapping = (changes: Partial<InputMapping>) =>
|
||||
setState(s => ({
|
||||
...s,
|
||||
inputMapping: { ...s.inputMapping, [selNode]: { ...s.inputMapping[selNode], ...changes } },
|
||||
}))
|
||||
const clearMapping = () =>
|
||||
setState(s => { const m = { ...s.inputMapping }; delete m[selNode]; return { ...s, inputMapping: m } })
|
||||
const fieldOptions: Record<string, { key: string; label: string }[]> = {
|
||||
market_watchlist: [{ key: 'close', label: 'Prix (close)' }, { key: 'change_pct', label: 'Variation %' }],
|
||||
economic_events: [{ key: 'actual', label: 'Valeur actuelle' }, { key: 'surprise', label: 'Surprise (actual−forecast)' }],
|
||||
ff_calendar: [{ key: 'actual', label: 'Valeur actuelle' }, { key: 'surprise', label: 'Surprise' }],
|
||||
}
|
||||
const dsKeys = mapping.source === 'market_watchlist' ? dataSources.prices
|
||||
: mapping.source === 'economic_events' ? dataSources.macro_series
|
||||
: mapping.source === 'ff_calendar' ? dataSources.ff_events : []
|
||||
return (
|
||||
<div className="bg-dark-700 rounded-lg p-3 border border-cyan-700/40 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-cyan-400 font-semibold uppercase tracking-wider">
|
||||
Mapping — {selNode}
|
||||
</span>
|
||||
{mapping.source && mapping.source !== 'user_input' && (
|
||||
<button onClick={clearMapping} className="text-xs text-slate-500 hover:text-red-400">Effacer</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500">Source</label>
|
||||
<select value={mapping.source || 'user_input'}
|
||||
onChange={e => updateMapping({ source: e.target.value as InputMapping['source'], key: '', field: 'close' })}
|
||||
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300">
|
||||
<option value="user_input">Manuel (saisie Analyser)</option>
|
||||
<option value="market_watchlist">Prix marché — yfinance</option>
|
||||
<option value="economic_events">Série macro — BD</option>
|
||||
<option value="ff_calendar">Calendrier Forex Factory</option>
|
||||
</select>
|
||||
</div>
|
||||
{mapping.source && mapping.source !== 'user_input' && (<>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500">Clé</label>
|
||||
<input list={`ds-${selNode}`}
|
||||
value={mapping.key || ''}
|
||||
onChange={e => updateMapping({ key: e.target.value })}
|
||||
placeholder={
|
||||
mapping.source === 'market_watchlist' ? 'ex: EURUSD' :
|
||||
mapping.source === 'economic_events' ? 'ex: NFP_US' : 'ex: Non-Farm Payrolls'
|
||||
}
|
||||
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
|
||||
<datalist id={`ds-${selNode}`}>
|
||||
{dsKeys.map(ds => <option key={ds.key} value={ds.key}>{ds.label}</option>)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500">Champ</label>
|
||||
<select value={mapping.field || (mapping.source === 'market_watchlist' ? 'close' : 'actual')}
|
||||
onChange={e => updateMapping({ field: e.target.value })}
|
||||
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300">
|
||||
{(fieldOptions[mapping.source] || []).map(f => (
|
||||
<option key={f.key} value={f.key}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Arêtes */}
|
||||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -876,16 +1009,68 @@ function TabEditor() {
|
||||
|
||||
<div className="space-y-1 max-h-52 overflow-y-auto">
|
||||
{state.edges.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5 rounded border border-slate-700/40 bg-dark-800/50 text-xs">
|
||||
<div className="w-8 h-0 flex-shrink-0"
|
||||
style={{ borderTop: `${edgeWidth(e.strength)}px ${e.style === 'dashed' ? 'dashed' : 'solid'} ${edgeColor(e.sign)}` }} />
|
||||
<span className="font-mono text-slate-400 truncate flex-1">
|
||||
{e.from} → {e.to}
|
||||
</span>
|
||||
{e.label && <span className="text-slate-500 truncate max-w-[60px]">{e.label}</span>}
|
||||
<button onClick={() => removeEdge(i)} className="text-slate-600 hover:text-red-400 flex-shrink-0">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
<div key={i}>
|
||||
<div className={clsx('flex items-center gap-2 px-2 py-1.5 rounded border text-xs cursor-pointer',
|
||||
editEdgeIdx === i
|
||||
? 'border-yellow-500/60 bg-yellow-900/10'
|
||||
: 'border-slate-700/40 bg-dark-800/50 hover:border-slate-500')}
|
||||
onClick={() => editEdgeIdx === i ? setEditEdge(null) : startEditEdge(i)}>
|
||||
<div className="w-8 h-0 flex-shrink-0"
|
||||
style={{ borderTop: `${edgeWidth(e.strength)}px ${e.style === 'dashed' ? 'dashed' : 'solid'} ${edgeColor(e.sign)}` }} />
|
||||
<span className="font-mono text-slate-400 truncate flex-1">
|
||||
{e.from} → {e.to}
|
||||
</span>
|
||||
{e.label && <span className="text-slate-500 truncate max-w-[60px]">{e.label}</span>}
|
||||
<button onClick={ev => { ev.stopPropagation(); removeEdge(i) }}
|
||||
className="text-slate-600 hover:text-red-400 flex-shrink-0">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{editEdgeIdx === i && (
|
||||
<div className="mt-1 p-2 bg-dark-800 rounded border border-yellow-700/30 space-y-1.5 text-xs">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<div>
|
||||
<label className="text-slate-500">Style</label>
|
||||
<select value={editEdge.style ?? e.style}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, style: ev.target.value as 'solid'|'dashed' }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||||
<option value="solid">Direct ──</option>
|
||||
<option value="dashed">Indirect ╌╌</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-500">Force</label>
|
||||
<select value={editEdge.strength ?? e.strength}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, strength: parseInt(ev.target.value) as 1|2|3 }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||||
<option value={1}>1 — fin</option>
|
||||
<option value={2}>2 — normal</option>
|
||||
<option value={3}>3 — fort</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-500">Signe</label>
|
||||
<select value={editEdge.sign ?? e.sign}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, sign: ev.target.value as 'positive'|'negative'|'neutral' }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||||
<option value="positive">+ positif</option>
|
||||
<option value="negative">− négatif</option>
|
||||
<option value="neutral">∅ neutre</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-500">Label</label>
|
||||
<input value={editEdge.label ?? e.label ?? ''}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, label: ev.target.value }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||||
</div>
|
||||
<button onClick={applyEdgeEdit}
|
||||
className="w-full py-1 bg-yellow-700 hover:bg-yellow-600 rounded text-xs text-white">
|
||||
Appliquer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{state.edges.length === 0 && (
|
||||
@@ -997,7 +1182,12 @@ function TabAnalyze() {
|
||||
|
||||
const currentTemplate = templates.find(t => t.id === selTmpl)
|
||||
const manualNodes = currentTemplate?.graph_json?.input_mapping
|
||||
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) => c.source === 'user_input')
|
||||
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) =>
|
||||
!c.source || c.source === 'user_input')
|
||||
: []
|
||||
const autoNodes = currentTemplate?.graph_json?.input_mapping
|
||||
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) =>
|
||||
c.source && c.source !== 'user_input')
|
||||
: []
|
||||
|
||||
async function recommend() {
|
||||
@@ -1078,6 +1268,19 @@ function TabAnalyze() {
|
||||
{['EURUSD', 'XAUUSD', 'SP500', 'BRENT'].map(i => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
|
||||
{autoNodes.length > 0 && (
|
||||
<div className="bg-dark-800/60 rounded p-3 space-y-1.5 border border-cyan-700/20">
|
||||
<div className="text-xs text-cyan-500 font-semibold flex items-center gap-1.5">
|
||||
<Zap className="w-3 h-3" /> Auto-récupéré depuis BD
|
||||
</div>
|
||||
{autoNodes.map(([id, cfg]) => (
|
||||
<div key={id} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-slate-500 flex-1 capitalize">{id.replace(/_/g, ' ')}</span>
|
||||
<span className="text-cyan-400/70 text-xs font-mono">{cfg.key ?? cfg.source}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{manualNodes.length > 0 && (
|
||||
<div className="bg-dark-800 rounded p-3 space-y-2 border border-slate-700/30">
|
||||
<div className="text-xs text-slate-400 font-semibold">Inputs manuels</div>
|
||||
@@ -1146,6 +1349,25 @@ function TabAnalyze() {
|
||||
|
||||
{result && (
|
||||
<>
|
||||
{Object.keys(result.inputs).length > 0 && (
|
||||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
|
||||
<div className="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-2">Inputs utilisés</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(result.inputs).map(([k, v]) => {
|
||||
const isAuto = autoNodes.some(([id]) => id === k)
|
||||
return (
|
||||
<span key={k} className={clsx('text-xs px-2 py-0.5 rounded border font-mono',
|
||||
isAuto
|
||||
? 'bg-cyan-900/20 text-cyan-300 border-cyan-700/30'
|
||||
: 'bg-slate-800 text-slate-300 border-slate-700/40')}>
|
||||
{k}: {typeof v === 'number' ? (v > 0 ? '+' : '') + v.toFixed(3) : String(v)}
|
||||
{isAuto && <span className="ml-1 text-cyan-600 text-xs">auto</span>}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-white font-semibold text-sm">{result.event.name}</h3>
|
||||
|
||||
Reference in New Issue
Block a user