[OQI-006] feat: Add 3 ML utility panel components
- ModelSelector: Model selection with dropdown/tabs/cards variants - EnsemblePanel: Ensemble voting configuration with weight sliders - ICTAnalysisPanel: ICT analysis parameters with collapsible sections Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
8347c6ad48
commit
f7a5ddcca8
379
src/modules/ml/components/EnsemblePanel.tsx
Normal file
379
src/modules/ml/components/EnsemblePanel.tsx
Normal file
@ -0,0 +1,379 @@
|
||||
/**
|
||||
* EnsemblePanel Component
|
||||
* Configuration panel for ensemble model weights and voting mechanism
|
||||
* OQI-006: Senales ML
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
Sliders,
|
||||
RotateCcw,
|
||||
Save,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
Lock,
|
||||
Unlock,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
Percent,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface ModelWeight {
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
weight: number;
|
||||
accuracy: number;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface EnsembleConfig {
|
||||
votingMethod: 'weighted' | 'majority' | 'unanimous';
|
||||
minimumAgreement: number;
|
||||
confidenceThreshold: number;
|
||||
weights: ModelWeight[];
|
||||
}
|
||||
|
||||
export interface EnsemblePanelProps {
|
||||
config: EnsembleConfig;
|
||||
onConfigChange: (config: EnsembleConfig) => void;
|
||||
onSave?: () => void;
|
||||
onReset?: () => void;
|
||||
isLoading?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const EnsemblePanel: React.FC<EnsemblePanelProps> = ({
|
||||
config,
|
||||
onConfigChange,
|
||||
onSave,
|
||||
onReset,
|
||||
isLoading = false,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const totalWeight = useMemo(
|
||||
() => config.weights.reduce((sum, w) => sum + w.weight, 0),
|
||||
[config.weights]
|
||||
);
|
||||
|
||||
const isValidConfig = useMemo(() => {
|
||||
return Math.abs(totalWeight - 100) < 0.1 && config.minimumAgreement >= 1;
|
||||
}, [totalWeight, config.minimumAgreement]);
|
||||
|
||||
const updateWeight = useCallback(
|
||||
(modelId: string, newWeight: number) => {
|
||||
if (readOnly) return;
|
||||
|
||||
const updatedWeights = config.weights.map((w) =>
|
||||
w.modelId === modelId ? { ...w, weight: Math.max(0, Math.min(100, newWeight)) } : w
|
||||
);
|
||||
|
||||
onConfigChange({ ...config, weights: updatedWeights });
|
||||
setHasChanges(true);
|
||||
},
|
||||
[config, onConfigChange, readOnly]
|
||||
);
|
||||
|
||||
const toggleLock = useCallback(
|
||||
(modelId: string) => {
|
||||
if (readOnly) return;
|
||||
|
||||
const updatedWeights = config.weights.map((w) =>
|
||||
w.modelId === modelId ? { ...w, locked: !w.locked } : w
|
||||
);
|
||||
|
||||
onConfigChange({ ...config, weights: updatedWeights });
|
||||
setHasChanges(true);
|
||||
},
|
||||
[config, onConfigChange, readOnly]
|
||||
);
|
||||
|
||||
const normalizeWeights = useCallback(() => {
|
||||
if (readOnly || totalWeight === 0) return;
|
||||
|
||||
const unlockedModels = config.weights.filter((w) => !w.locked);
|
||||
const lockedTotal = config.weights
|
||||
.filter((w) => w.locked)
|
||||
.reduce((sum, w) => sum + w.weight, 0);
|
||||
|
||||
const remainingWeight = 100 - lockedTotal;
|
||||
const unlockedTotal = unlockedModels.reduce((sum, w) => sum + w.weight, 0);
|
||||
|
||||
const updatedWeights = config.weights.map((w) => {
|
||||
if (w.locked) return w;
|
||||
return {
|
||||
...w,
|
||||
weight: unlockedTotal > 0 ? (w.weight / unlockedTotal) * remainingWeight : remainingWeight / unlockedModels.length,
|
||||
};
|
||||
});
|
||||
|
||||
onConfigChange({ ...config, weights: updatedWeights });
|
||||
setHasChanges(true);
|
||||
}, [config, onConfigChange, readOnly, totalWeight]);
|
||||
|
||||
const autoWeightByAccuracy = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
|
||||
const totalAccuracy = config.weights.reduce((sum, w) => sum + w.accuracy, 0);
|
||||
const updatedWeights = config.weights.map((w) => ({
|
||||
...w,
|
||||
weight: (w.accuracy / totalAccuracy) * 100,
|
||||
locked: false,
|
||||
}));
|
||||
|
||||
onConfigChange({ ...config, weights: updatedWeights });
|
||||
setHasChanges(true);
|
||||
}, [config, onConfigChange, readOnly]);
|
||||
|
||||
const getWeightColor = (weight: number) => {
|
||||
if (weight >= 30) return 'bg-green-500';
|
||||
if (weight >= 15) return 'bg-blue-500';
|
||||
if (weight >= 5) return 'bg-yellow-500';
|
||||
return 'bg-gray-500';
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave && isValidConfig) {
|
||||
onSave();
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (onReset) {
|
||||
onReset();
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/50 rounded-xl border border-gray-700 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-500/20 text-purple-400 rounded-lg">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Ensemble Configuration</h3>
|
||||
<p className="text-xs text-gray-400">Configure model weights and voting</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isLoading || !hasChanges}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !hasChanges || !isValidConfig}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Voting Method */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm text-gray-400 mb-2">Voting Method</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['weighted', 'majority', 'unanimous'] as const).map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
onClick={() => !readOnly && onConfigChange({ ...config, votingMethod: method })}
|
||||
disabled={readOnly}
|
||||
className={`p-3 rounded-lg border transition-all ${
|
||||
config.votingMethod === method
|
||||
? 'bg-purple-500/20 border-purple-500 text-purple-400'
|
||||
: 'bg-gray-900/50 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||
} ${readOnly ? 'cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div className="text-sm font-medium capitalize">{method}</div>
|
||||
<div className="text-xs mt-1 text-gray-500">
|
||||
{method === 'weighted' && 'Use weight distribution'}
|
||||
{method === 'majority' && '>50% agreement'}
|
||||
{method === 'unanimous' && 'All models agree'}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thresholds */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400 mb-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Minimum Agreement
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max={config.weights.length}
|
||||
value={config.minimumAgreement}
|
||||
onChange={(e) => !readOnly && onConfigChange({ ...config, minimumAgreement: Number(e.target.value) })}
|
||||
disabled={readOnly}
|
||||
className="flex-1 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500"
|
||||
/>
|
||||
<span className="text-white font-medium w-12 text-center">
|
||||
{config.minimumAgreement}/{config.weights.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400 mb-2">
|
||||
<Percent className="w-4 h-4" />
|
||||
Confidence Threshold
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={config.confidenceThreshold}
|
||||
onChange={(e) => !readOnly && onConfigChange({ ...config, confidenceThreshold: Number(e.target.value) })}
|
||||
disabled={readOnly}
|
||||
className="flex-1 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500"
|
||||
/>
|
||||
<span className="text-white font-medium w-12 text-center">{config.confidenceThreshold}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Weights */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Sliders className="w-4 h-4" />
|
||||
Model Weights
|
||||
</label>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={normalizeWeights}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Normalize to 100%
|
||||
</button>
|
||||
<span className="text-gray-600">|</span>
|
||||
<button
|
||||
onClick={autoWeightByAccuracy}
|
||||
className="text-xs text-purple-400 hover:text-purple-300 transition-colors"
|
||||
>
|
||||
Auto (by accuracy)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Weight validation */}
|
||||
<div
|
||||
className={`mb-3 px-3 py-2 rounded-lg flex items-center gap-2 text-sm ${
|
||||
isValidConfig
|
||||
? 'bg-green-500/10 text-green-400 border border-green-500/30'
|
||||
: 'bg-red-500/10 text-red-400 border border-red-500/30'
|
||||
}`}
|
||||
>
|
||||
{isValidConfig ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>Total: {totalWeight.toFixed(1)}% - Configuration valid</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>Total: {totalWeight.toFixed(1)}% - Must equal 100%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Weight sliders */}
|
||||
<div className="space-y-3">
|
||||
{config.weights.map((model) => (
|
||||
<div key={model.modelId} className="p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleLock(model.modelId)}
|
||||
disabled={readOnly}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
model.locked ? 'text-yellow-400 bg-yellow-500/20' : 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{model.locked ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
|
||||
</button>
|
||||
<span className="font-medium text-white">{model.modelName}</span>
|
||||
<span className="text-xs text-gray-500">({model.accuracy}% acc)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={model.weight.toFixed(1)}
|
||||
onChange={(e) => updateWeight(model.modelId, Number(e.target.value))}
|
||||
disabled={readOnly || model.locked}
|
||||
className="w-16 px-2 py-1 text-sm text-right bg-gray-800 border border-gray-700 rounded text-white disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-gray-400">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`absolute h-full transition-all ${getWeightColor(model.weight)}`}
|
||||
style={{ width: `${model.weight}%` }}
|
||||
/>
|
||||
</div>
|
||||
{!readOnly && !model.locked && (
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
value={model.weight}
|
||||
onChange={(e) => updateWeight(model.modelId, Number(e.target.value))}
|
||||
className="w-full h-2 mt-2 bg-transparent appearance-none cursor-pointer accent-blue-500"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-gray-300">
|
||||
<p className="mb-1">
|
||||
<strong className="text-blue-400">Weighted voting:</strong> Each model's prediction is multiplied by its weight.
|
||||
</p>
|
||||
<p className="mb-1">
|
||||
<strong className="text-blue-400">Majority voting:</strong> Signal generated when majority of models agree.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-blue-400">Unanimous:</strong> All models must agree for a signal to be generated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnsemblePanel;
|
||||
475
src/modules/ml/components/ICTAnalysisPanel.tsx
Normal file
475
src/modules/ml/components/ICTAnalysisPanel.tsx
Normal file
@ -0,0 +1,475 @@
|
||||
/**
|
||||
* ICTAnalysisPanel Component
|
||||
* Configuration panel for ICT (Inner Circle Trader) analysis parameters
|
||||
* OQI-006: Senales ML
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Settings2,
|
||||
Target,
|
||||
TrendingUp,
|
||||
BarChart2,
|
||||
Clock,
|
||||
Layers,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RotateCcw,
|
||||
Save,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface ICTParams {
|
||||
// Timeframe settings
|
||||
timeframe: '1m' | '5m' | '15m' | '1h' | '4h' | '1d';
|
||||
htfBias: '15m' | '1h' | '4h' | '1d' | '1w';
|
||||
|
||||
// Order Block settings
|
||||
orderBlocks: {
|
||||
enabled: boolean;
|
||||
lookback: number;
|
||||
minImbalance: number;
|
||||
showMitigated: boolean;
|
||||
};
|
||||
|
||||
// Fair Value Gap settings
|
||||
fvg: {
|
||||
enabled: boolean;
|
||||
minSize: number;
|
||||
showFilled: boolean;
|
||||
filterByTrend: boolean;
|
||||
};
|
||||
|
||||
// Market Structure settings
|
||||
marketStructure: {
|
||||
enabled: boolean;
|
||||
swingLookback: number;
|
||||
showBOS: boolean;
|
||||
showCHOCH: boolean;
|
||||
};
|
||||
|
||||
// Liquidity settings
|
||||
liquidity: {
|
||||
enabled: boolean;
|
||||
showBSL: boolean;
|
||||
showSSL: boolean;
|
||||
equalHighsLows: boolean;
|
||||
};
|
||||
|
||||
// Session settings
|
||||
sessions: {
|
||||
showAsian: boolean;
|
||||
showLondon: boolean;
|
||||
showNewYork: boolean;
|
||||
highlightKillzones: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ICTAnalysisPanelProps {
|
||||
params: ICTParams;
|
||||
onParamsChange: (params: ICTParams) => void;
|
||||
onSave?: () => void;
|
||||
onReset?: () => void;
|
||||
isLoading?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const ICTAnalysisPanel: React.FC<ICTAnalysisPanelProps> = ({
|
||||
params,
|
||||
onParamsChange,
|
||||
onSave,
|
||||
onReset,
|
||||
isLoading = false,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>(['timeframe', 'orderBlocks']);
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) =>
|
||||
prev.includes(section) ? prev.filter((s) => s !== section) : [...prev, section]
|
||||
);
|
||||
};
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updates: Partial<ICTParams>) => {
|
||||
if (readOnly) return;
|
||||
onParamsChange({ ...params, ...updates });
|
||||
setHasChanges(true);
|
||||
},
|
||||
[params, onParamsChange, readOnly]
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave) {
|
||||
onSave();
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (onReset) {
|
||||
onReset();
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1d'] as const;
|
||||
const htfOptions = ['15m', '1h', '4h', '1d', '1w'] as const;
|
||||
|
||||
const SectionHeader: React.FC<{
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
enabled?: boolean;
|
||||
onToggle?: (enabled: boolean) => void;
|
||||
}> = ({ id, icon, title, enabled, onToggle }) => (
|
||||
<button
|
||||
onClick={() => toggleSection(id)}
|
||||
className="w-full flex items-center justify-between p-3 bg-gray-900/50 rounded-lg hover:bg-gray-900 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 bg-blue-500/20 text-blue-400 rounded">{icon}</div>
|
||||
<span className="font-medium text-white">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{enabled !== undefined && onToggle && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!readOnly) onToggle(!enabled);
|
||||
}}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
enabled ? 'text-green-400 bg-green-500/20' : 'text-gray-500 bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{enabled ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{expandedSections.includes(id) ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/50 rounded-xl border border-gray-700 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-cyan-500/20 text-cyan-400 rounded-lg">
|
||||
<Settings2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">ICT Analysis Settings</h3>
|
||||
<p className="text-xs text-gray-400">Configure market structure detection</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isLoading || !hasChanges}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !hasChanges}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-cyan-500 text-white rounded-lg hover:bg-cyan-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-3">
|
||||
{/* Timeframe Section */}
|
||||
<div>
|
||||
<SectionHeader id="timeframe" icon={<Clock className="w-4 h-4" />} title="Timeframe" />
|
||||
{expandedSections.includes('timeframe') && (
|
||||
<div className="mt-2 p-3 bg-gray-900/30 rounded-lg space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Analysis Timeframe</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{timeframes.map((tf) => (
|
||||
<button
|
||||
key={tf}
|
||||
onClick={() => updateParams({ timeframe: tf })}
|
||||
disabled={readOnly}
|
||||
className={`px-3 py-1.5 rounded text-sm transition-colors ${
|
||||
params.timeframe === tf
|
||||
? 'bg-cyan-500 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">HTF Bias Reference</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{htfOptions.map((tf) => (
|
||||
<button
|
||||
key={tf}
|
||||
onClick={() => updateParams({ htfBias: tf })}
|
||||
disabled={readOnly}
|
||||
className={`px-3 py-1.5 rounded text-sm transition-colors ${
|
||||
params.htfBias === tf
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order Blocks Section */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
id="orderBlocks"
|
||||
icon={<Target className="w-4 h-4" />}
|
||||
title="Order Blocks"
|
||||
enabled={params.orderBlocks.enabled}
|
||||
onToggle={(enabled) => updateParams({ orderBlocks: { ...params.orderBlocks, enabled } })}
|
||||
/>
|
||||
{expandedSections.includes('orderBlocks') && params.orderBlocks.enabled && (
|
||||
<div className="mt-2 p-3 bg-gray-900/30 rounded-lg space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center justify-between text-sm text-gray-400 mb-2">
|
||||
<span>Lookback Period</span>
|
||||
<span className="text-white">{params.orderBlocks.lookback} candles</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
value={params.orderBlocks.lookback}
|
||||
onChange={(e) =>
|
||||
updateParams({ orderBlocks: { ...params.orderBlocks, lookback: Number(e.target.value) } })
|
||||
}
|
||||
disabled={readOnly}
|
||||
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center justify-between text-sm text-gray-400 mb-2">
|
||||
<span>Min Imbalance %</span>
|
||||
<span className="text-white">{params.orderBlocks.minImbalance}%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="5"
|
||||
step="0.1"
|
||||
value={params.orderBlocks.minImbalance}
|
||||
onChange={(e) =>
|
||||
updateParams({ orderBlocks: { ...params.orderBlocks, minImbalance: Number(e.target.value) } })
|
||||
}
|
||||
disabled={readOnly}
|
||||
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.orderBlocks.showMitigated}
|
||||
onChange={(e) =>
|
||||
updateParams({ orderBlocks: { ...params.orderBlocks, showMitigated: e.target.checked } })
|
||||
}
|
||||
disabled={readOnly}
|
||||
className="rounded border-gray-600 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
Show mitigated order blocks
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fair Value Gap Section */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
id="fvg"
|
||||
icon={<BarChart2 className="w-4 h-4" />}
|
||||
title="Fair Value Gaps"
|
||||
enabled={params.fvg.enabled}
|
||||
onToggle={(enabled) => updateParams({ fvg: { ...params.fvg, enabled } })}
|
||||
/>
|
||||
{expandedSections.includes('fvg') && params.fvg.enabled && (
|
||||
<div className="mt-2 p-3 bg-gray-900/30 rounded-lg space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center justify-between text-sm text-gray-400 mb-2">
|
||||
<span>Minimum Gap Size</span>
|
||||
<span className="text-white">{params.fvg.minSize} pips</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="50"
|
||||
value={params.fvg.minSize}
|
||||
onChange={(e) => updateParams({ fvg: { ...params.fvg, minSize: Number(e.target.value) } })}
|
||||
disabled={readOnly}
|
||||
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.fvg.showFilled}
|
||||
onChange={(e) => updateParams({ fvg: { ...params.fvg, showFilled: e.target.checked } })}
|
||||
disabled={readOnly}
|
||||
className="rounded border-gray-600 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
Show filled gaps
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.fvg.filterByTrend}
|
||||
onChange={(e) => updateParams({ fvg: { ...params.fvg, filterByTrend: e.target.checked } })}
|
||||
disabled={readOnly}
|
||||
className="rounded border-gray-600 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
Filter by HTF trend direction
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Market Structure Section */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
id="marketStructure"
|
||||
icon={<TrendingUp className="w-4 h-4" />}
|
||||
title="Market Structure"
|
||||
enabled={params.marketStructure.enabled}
|
||||
onToggle={(enabled) => updateParams({ marketStructure: { ...params.marketStructure, enabled } })}
|
||||
/>
|
||||
{expandedSections.includes('marketStructure') && params.marketStructure.enabled && (
|
||||
<div className="mt-2 p-3 bg-gray-900/30 rounded-lg space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center justify-between text-sm text-gray-400 mb-2">
|
||||
<span>Swing Lookback</span>
|
||||
<span className="text-white">{params.marketStructure.swingLookback} candles</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="3"
|
||||
max="20"
|
||||
value={params.marketStructure.swingLookback}
|
||||
onChange={(e) =>
|
||||
updateParams({ marketStructure: { ...params.marketStructure, swingLookback: Number(e.target.value) } })
|
||||
}
|
||||
disabled={readOnly}
|
||||
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.marketStructure.showBOS}
|
||||
onChange={(e) =>
|
||||
updateParams({ marketStructure: { ...params.marketStructure, showBOS: e.target.checked } })
|
||||
}
|
||||
disabled={readOnly}
|
||||
className="rounded border-gray-600 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
Show Break of Structure (BOS)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.marketStructure.showCHOCH}
|
||||
onChange={(e) =>
|
||||
updateParams({ marketStructure: { ...params.marketStructure, showCHOCH: e.target.checked } })
|
||||
}
|
||||
disabled={readOnly}
|
||||
className="rounded border-gray-600 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
Show Change of Character (CHOCH)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Liquidity Section */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
id="liquidity"
|
||||
icon={<Layers className="w-4 h-4" />}
|
||||
title="Liquidity"
|
||||
enabled={params.liquidity.enabled}
|
||||
onToggle={(enabled) => updateParams({ liquidity: { ...params.liquidity, enabled } })}
|
||||
/>
|
||||
{expandedSections.includes('liquidity') && params.liquidity.enabled && (
|
||||
<div className="mt-2 p-3 bg-gray-900/30 rounded-lg space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.liquidity.showBSL}
|
||||
onChange={(e) => updateParams({ liquidity: { ...params.liquidity, showBSL: e.target.checked } })}
|
||||
disabled={readOnly}
|
||||
className="rounded border-gray-600 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
Show Buy Side Liquidity (BSL)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.liquidity.showSSL}
|
||||
onChange={(e) => updateParams({ liquidity: { ...params.liquidity, showSSL: e.target.checked } })}
|
||||
disabled={readOnly}
|
||||
className="rounded border-gray-600 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
Show Sell Side Liquidity (SSL)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.liquidity.equalHighsLows}
|
||||
onChange={(e) => updateParams({ liquidity: { ...params.liquidity, equalHighsLows: e.target.checked } })}
|
||||
disabled={readOnly}
|
||||
className="rounded border-gray-600 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
Highlight Equal Highs/Lows
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-4 flex items-start gap-2 p-3 bg-cyan-500/10 border border-cyan-500/30 rounded-lg">
|
||||
<Info className="w-4 h-4 text-cyan-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-gray-300">
|
||||
<p>
|
||||
ICT (Inner Circle Trader) concepts help identify institutional order flow through market structure,
|
||||
order blocks, and liquidity zones. Adjust sensitivity based on your trading timeframe.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ICTAnalysisPanel;
|
||||
274
src/modules/ml/components/ModelSelector.tsx
Normal file
274
src/modules/ml/components/ModelSelector.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
/**
|
||||
* ModelSelector Component
|
||||
* Dropdown/tabs interface to switch between ML prediction models
|
||||
* OQI-006: Senales ML
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Brain,
|
||||
ChevronDown,
|
||||
Check,
|
||||
Cpu,
|
||||
LineChart,
|
||||
Network,
|
||||
Layers,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface MLModel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'lstm' | 'xgboost' | 'random_forest' | 'svm' | 'ensemble' | 'transformer';
|
||||
accuracy: number;
|
||||
lastUpdated: string;
|
||||
status: 'active' | 'training' | 'inactive' | 'deprecated';
|
||||
description?: string;
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
export interface ModelSelectorProps {
|
||||
models: MLModel[];
|
||||
selectedModelId: string;
|
||||
onModelChange: (modelId: string) => void;
|
||||
variant?: 'dropdown' | 'tabs' | 'cards';
|
||||
showMetrics?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
models,
|
||||
selectedModelId,
|
||||
onModelChange,
|
||||
variant = 'dropdown',
|
||||
showMetrics = true,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedModel = useMemo(
|
||||
() => models.find((m) => m.id === selectedModelId),
|
||||
[models, selectedModelId]
|
||||
);
|
||||
|
||||
// Close dropdown on outside click
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const getModelIcon = (type: MLModel['type']) => {
|
||||
switch (type) {
|
||||
case 'lstm':
|
||||
return <Network className="w-4 h-4" />;
|
||||
case 'xgboost':
|
||||
return <TrendingUp className="w-4 h-4" />;
|
||||
case 'random_forest':
|
||||
return <Layers className="w-4 h-4" />;
|
||||
case 'svm':
|
||||
return <LineChart className="w-4 h-4" />;
|
||||
case 'ensemble':
|
||||
return <Sparkles className="w-4 h-4" />;
|
||||
case 'transformer':
|
||||
return <Cpu className="w-4 h-4" />;
|
||||
default:
|
||||
return <Brain className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: MLModel['status']) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-400 bg-green-500/20';
|
||||
case 'training':
|
||||
return 'text-yellow-400 bg-yellow-500/20';
|
||||
case 'inactive':
|
||||
return 'text-gray-400 bg-gray-500/20';
|
||||
case 'deprecated':
|
||||
return 'text-red-400 bg-red-500/20';
|
||||
default:
|
||||
return 'text-gray-400 bg-gray-500/20';
|
||||
}
|
||||
};
|
||||
|
||||
const getAccuracyColor = (accuracy: number) => {
|
||||
if (accuracy >= 70) return 'text-green-400';
|
||||
if (accuracy >= 50) return 'text-yellow-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
// Dropdown variant
|
||||
if (variant === 'dropdown') {
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<button
|
||||
onClick={() => !disabled && !loading && setIsOpen(!isOpen)}
|
||||
disabled={disabled || loading}
|
||||
className={`w-full flex items-center justify-between gap-3 px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg transition-colors ${
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : selectedModel ? (
|
||||
<>
|
||||
<div className="p-1.5 bg-blue-500/20 text-blue-400 rounded">
|
||||
{getModelIcon(selectedModel.type)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-white">{selectedModel.name}</div>
|
||||
{showMetrics && (
|
||||
<div className="text-xs text-gray-400">
|
||||
Accuracy: <span className={getAccuracyColor(selectedModel.accuracy)}>{selectedModel.accuracy}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">Select a model</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={`w-5 h-5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-gray-800 border border-gray-700 rounded-lg shadow-xl overflow-hidden">
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => {
|
||||
onModelChange(model.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
disabled={model.status === 'deprecated'}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors ${
|
||||
model.id === selectedModelId
|
||||
? 'bg-blue-500/20 border-l-2 border-blue-500'
|
||||
: 'hover:bg-gray-700/50'
|
||||
} ${model.status === 'deprecated' ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div className={`p-1.5 rounded ${model.id === selectedModelId ? 'bg-blue-500/30 text-blue-400' : 'bg-gray-700 text-gray-400'}`}>
|
||||
{getModelIcon(model.type)}
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{model.name}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${getStatusColor(model.status)}`}>
|
||||
{model.status}
|
||||
</span>
|
||||
</div>
|
||||
{showMetrics && (
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
Accuracy: <span className={getAccuracyColor(model.accuracy)}>{model.accuracy}%</span>
|
||||
{model.description && <span className="ml-2">- {model.description}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{model.id === selectedModelId && <Check className="w-5 h-5 text-blue-400" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tabs variant
|
||||
if (variant === 'tabs') {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 p-1 bg-gray-800/50 rounded-lg">
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => onModelChange(model.id)}
|
||||
disabled={disabled || model.status === 'deprecated'}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-all ${
|
||||
model.id === selectedModelId
|
||||
? 'bg-blue-500 text-white shadow-lg'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700/50'
|
||||
} ${model.status === 'deprecated' ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{getModelIcon(model.type)}
|
||||
<span className="font-medium">{model.name}</span>
|
||||
{showMetrics && (
|
||||
<span className={`text-xs ${model.id === selectedModelId ? 'text-blue-100' : getAccuracyColor(model.accuracy)}`}>
|
||||
{model.accuracy}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Cards variant
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => onModelChange(model.id)}
|
||||
disabled={disabled || model.status === 'deprecated'}
|
||||
className={`relative p-4 rounded-xl border transition-all text-left ${
|
||||
model.id === selectedModelId
|
||||
? 'bg-blue-500/20 border-blue-500 shadow-lg shadow-blue-500/20'
|
||||
: 'bg-gray-800/50 border-gray-700 hover:border-gray-600 hover:bg-gray-800'
|
||||
} ${model.status === 'deprecated' ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{model.id === selectedModelId && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Check className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`p-2 rounded-lg ${model.id === selectedModelId ? 'bg-blue-500/30 text-blue-400' : 'bg-gray-700 text-gray-400'}`}>
|
||||
{getModelIcon(model.type)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-white">{model.name}</div>
|
||||
<div className={`text-xs px-1.5 py-0.5 rounded inline-block ${getStatusColor(model.status)}`}>
|
||||
{model.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{model.description && (
|
||||
<p className="text-sm text-gray-400 mb-3 line-clamp-2">{model.description}</p>
|
||||
)}
|
||||
|
||||
{showMetrics && (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-700">
|
||||
<div className="text-sm text-gray-400">Accuracy</div>
|
||||
<div className={`text-lg font-bold ${getAccuracyColor(model.accuracy)}`}>
|
||||
{model.accuracy}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{model.status === 'training' && (
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-yellow-400">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<span>Model is currently training</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSelector;
|
||||
@ -20,3 +20,11 @@ export { default as ModelAccuracyDashboard } from './ModelAccuracyDashboard';
|
||||
export type { ModelMetrics } from './ModelAccuracyDashboard';
|
||||
export { default as BacktestResultsVisualization } from './BacktestResultsVisualization';
|
||||
export type { BacktestResult, BacktestTrade } from './BacktestResultsVisualization';
|
||||
|
||||
// Model Selection & Configuration Components (OQI-006)
|
||||
export { default as ModelSelector } from './ModelSelector';
|
||||
export type { MLModel, ModelSelectorProps } from './ModelSelector';
|
||||
export { default as EnsemblePanel } from './EnsemblePanel';
|
||||
export type { ModelWeight, EnsembleConfig, EnsemblePanelProps } from './EnsemblePanel';
|
||||
export { default as ICTAnalysisPanel } from './ICTAnalysisPanel';
|
||||
export type { ICTParams, ICTAnalysisPanelProps } from './ICTAnalysisPanel';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user