feat: wavelets

This commit is contained in:
OpenSquared
2026-07-19 16:34:53 +02:00
parent 9079d4e9c5
commit e61bc50106
2 changed files with 46 additions and 5 deletions

View File

@@ -364,12 +364,31 @@ export type WaveletOptimizationResult = {
analysisMethod?: 'cwt' | 'ssq'
}
// Trend-confirmation filter: require a SECONDARY band's slope to align with the
// direction implied by each trigger before it's allowed to fire — e.g. only take band
// 1's trough (buy) if band 3 is sloping up, only take its peak (sell) if band 3 is
// sloping down. Buy/cover expect an upward move so they require a positive slope;
// sell/short expect a downward move so they require a negative slope. `null` disables
// the filter entirely (every buyFilters/etc. array stays empty, unchanged behaviour).
function trendConfirmationFilters(filterCurveId: string | null): {
buy: WaveletSlopeFilter[]; sell: WaveletSlopeFilter[]; short: WaveletSlopeFilter[]; cover: WaveletSlopeFilter[]
} {
if (!filterCurveId) return { buy: [], sell: [], short: [], cover: [] }
return {
buy: [{ curveId: filterCurveId, sign: 'positive' }],
sell: [{ curveId: filterCurveId, sign: 'negative' }],
short: [{ curveId: filterCurveId, sign: 'negative' }],
cover: [{ curveId: filterCurveId, sign: 'positive' }],
}
}
// All grid combos use the SAME trigger kind/band symmetrically for both sides of a
// position with each kind's validated default parameters — testing every
// combination of *different* kinds for entry vs exit would multiply the grid by
// another 5x for little practical benefit.
export function buildSymmetricTradeConfig(
curveId: string, triggerKind: WaveletTriggerKind, mode: WaveletPositionMode, variant: WaveletExitVariant,
filterCurveId: string | null = null,
): WaveletTradeConfig {
const effectiveCurveId =
triggerKind === 'ridge_shift' ? 'ridge' :
@@ -379,10 +398,11 @@ export function buildSymmetricTradeConfig(
// ridge_shift's period series is coarser than a price band, so it rarely crosses
// a full standard deviation — the shared default K=1 starves it of trades.
if (triggerKind === 'ridge_shift') trigger.levelThresholdK = 0.5
const filters = trendConfirmationFilters(filterCurveId)
return {
mode,
buyTrigger: trigger, sellTrigger: trigger, buyFilters: [], sellFilters: [],
shortTrigger: trigger, coverTrigger: trigger, shortFilters: [], coverFilters: [],
buyTrigger: trigger, sellTrigger: trigger, buyFilters: filters.buy, sellFilters: filters.sell,
shortTrigger: trigger, coverTrigger: trigger, shortFilters: filters.short, coverFilters: filters.cover,
exitStyle: variant.exitStyle, takeProfitPct: variant.takeProfitPct, stopLossPct: variant.stopLossPct,
}
}
@@ -391,6 +411,7 @@ export function runOptimizationGrid(
symbol: string, lookback: number, wavelet: string, rolling: any,
bandsToTest: number[], kindsToTest: WaveletTriggerKind[], modesToTest: WaveletPositionMode[],
exitVariants: WaveletExitVariant[],
filterBandIndex: number | null = null,
): WaveletOptimizationResult[] {
const curves: WaveletCurve[] = rolling.bands.map((band: any) => ({ id: `band_${band.index}`, label: band.label, series: band.series }))
if (rolling.ridge_period_days) {
@@ -400,6 +421,7 @@ export function runOptimizationGrid(
if (band.energy) curves.push({ id: `energy_band_${band.index}`, label: `Energie ${band.label}`, series: band.energy })
})
const availableBandIndices = new Set(rolling.bands.map((band: any) => band.index))
const filterCurveId = filterBandIndex != null && availableBandIndices.has(filterBandIndex) ? `band_${filterBandIndex}` : null
const results: WaveletOptimizationResult[] = []
let ridgeShiftComputed = false
for (const bandIndex of bandsToTest) {
@@ -409,7 +431,7 @@ export function runOptimizationGrid(
if (kind === 'ridge_shift' && ridgeShiftComputed) continue
for (const mode of modesToTest) {
for (const variant of exitVariants) {
const config = buildSymmetricTradeConfig(curveId, kind, mode, variant)
const config = buildSymmetricTradeConfig(curveId, kind, mode, variant, filterCurveId)
const sim = runWaveletTradeSimulation(rolling.dates, rolling.original, curves, config)
results.push({
symbol, lookback, wavelet, mode,
@@ -438,6 +460,7 @@ export async function runFullOptimization(
onProgress: (done: number, total: number, label: string, failures: string[]) => void,
onBatchResults: (results: WaveletOptimizationResult[]) => void,
method: 'cwt' | 'ssq' = 'cwt',
filterBandIndex: number | null = null,
): Promise<void> {
const combos: { symbol: string; lookback: number; wavelet: string }[] = []
for (const symbol of instruments) {
@@ -455,7 +478,7 @@ export async function runFullOptimization(
const { data: rolling } = await api.get('/wavelet/rolling', {
params: { symbol, period, levels, wavelet, lookback, step: 1, method },
})
onBatchResults(runOptimizationGrid(symbol, lookback, wavelet, rolling, bandsToTest, kindsToTest, modesToTest, exitVariants))
onBatchResults(runOptimizationGrid(symbol, lookback, wavelet, rolling, bandsToTest, kindsToTest, modesToTest, exitVariants, filterBandIndex))
} catch (error) {
failures.push(`${symbol} @ ${lookback}j (${wavelet}): ${error instanceof Error ? error.message : String(error)}`)
}

View File

@@ -66,6 +66,7 @@ export default function WaveletsSimulation() {
const [modes, setModes] = useState<Set<WaveletPositionMode>>(new Set(['both']))
const [takeProfits, setTakeProfits] = useState<number[]>([0])
const [stopLosses, setStopLosses] = useState<number[]>([0])
const [filterBand, setFilterBand] = useState<number | null>(null)
const [simName, setSimName] = useState(() => `Run ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`)
// ── Run state ────────────────────────────────────────────────────────────────
@@ -103,7 +104,7 @@ export default function WaveletsSimulation() {
const form = {
instruments: [...selectedInstruments], period, levels, wavelets: [...wavelets],
lookbacks, bands: [...bands], kinds: [...kinds], modes: [...modes],
takeProfits, stopLosses, method,
takeProfits, stopLosses, method, filterBand,
}
const created = await api.post('/wavelet/simulations', { name: simName, form, results: [] }).then(r => r.data)
setActiveSimId(created.id)
@@ -124,6 +125,7 @@ export default function WaveletsSimulation() {
try { await api.patch(`/wavelet/simulations/${created.id}`, { append_results: batch }) } catch { /* non-blocking */ }
},
method,
filterBand,
)
setRunning(false)
qc.invalidateQueries({ queryKey: ['wavelet-simulations'] })
@@ -286,6 +288,22 @@ export default function WaveletsSimulation() {
</div>
</div>
<div>
<div className="text-slate-500 text-xs mb-1" title="Un retournement (extremum, seuil...) d'une bande à tester ne se déclenche que si cette bande secondaire confirme la même direction (pente positive pour un achat/cover, négative pour une vente/short) — ex. creux de bande 1 achète seulement si bande 3 monte.">
Filtre de tendance (bande de confirmation)
</div>
<div className="flex gap-2 flex-wrap text-xs">
<label className="flex items-center gap-1 cursor-pointer">
<input type="radio" checked={filterBand === null} onChange={() => setFilterBand(null)} /> Aucun
</label>
{ALL_BANDS.slice(0, levels).map(b => (
<label key={b} className="flex items-center gap-1 cursor-pointer">
<input type="radio" checked={filterBand === b} onChange={() => setFilterBand(b)} /> Bande {b}
</label>
))}
</div>
</div>
<div>
<div className="text-slate-500 text-xs mb-1">Types de déclencheur</div>
<div className="flex gap-3 flex-wrap text-xs">