feat: wavelets
This commit is contained in:
464
frontend/src/lib/waveletTrade.ts
Normal file
464
frontend/src/lib/waveletTrade.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
// Wavelet trigger-signal detection + trade simulation engine.
|
||||
// Ported near-verbatim from c:\DataS\InstrumentSimulator\frontend\src\main.tsx
|
||||
// (lines 62-654, project "Macro Causal Lab") — pure functions, no React/component
|
||||
// dependencies, safe to unit-reason-about independently of the page that uses them.
|
||||
import { api } from '../hooks/useApi'
|
||||
|
||||
export type WaveletCurve = { id: string; label: string; series: number[] }
|
||||
// `kind` is optional and defaults to "slope" (the original, only behaviour)
|
||||
// wherever it's read - every filter created before this field existed has
|
||||
// kind===undefined, which must keep behaving exactly like "slope".
|
||||
export type WaveletSlopeFilter = { curveId: string; sign: 'positive' | 'negative'; kind?: 'slope' | 'energy' }
|
||||
export type WaveletTriggerKind = 'extremum' | 'trend_flatten' | 'acceleration' | 'level_threshold' | 'band_cross' | 'ridge_shift' | 'energy_threshold'
|
||||
export type WaveletTrigger = {
|
||||
kind: WaveletTriggerKind
|
||||
curveId: string
|
||||
secondaryCurveId: string
|
||||
trendDays: number
|
||||
flattenDays: number
|
||||
trendThresholdK: number
|
||||
flattenThresholdK: number
|
||||
accelDays: number
|
||||
accelThresholdK: number
|
||||
levelThresholdK: number
|
||||
}
|
||||
export type WaveletPositionMode = 'long_only' | 'short_only' | 'both'
|
||||
// "signal": close only on the mirror trigger (current behaviour).
|
||||
// "pct": close only on a take-profit/stop-loss percentage target, ignore the exit trigger.
|
||||
// "signal_or_pct": close on whichever happens first.
|
||||
// P&L is tracked as a percentage return, not FX "pips" — that convention is wrong for
|
||||
// anything not quoted like a 4-decimal forex pair, and results get aggregated across
|
||||
// heterogeneous instruments (metals, energy, indices, forex, ...) so the P&L unit has
|
||||
// to mean the same thing for every one of them.
|
||||
export type WaveletExitStyle = 'signal' | 'pct' | 'signal_or_pct'
|
||||
export type WaveletTradeConfig = {
|
||||
mode: WaveletPositionMode
|
||||
buyTrigger: WaveletTrigger
|
||||
sellTrigger: WaveletTrigger
|
||||
buyFilters: WaveletSlopeFilter[]
|
||||
sellFilters: WaveletSlopeFilter[]
|
||||
shortTrigger: WaveletTrigger
|
||||
coverTrigger: WaveletTrigger
|
||||
shortFilters: WaveletSlopeFilter[]
|
||||
coverFilters: WaveletSlopeFilter[]
|
||||
exitStyle: WaveletExitStyle
|
||||
takeProfitPct: number | null
|
||||
stopLossPct: number | null
|
||||
}
|
||||
export type WaveletTrade = {
|
||||
direction: 'long' | 'short'
|
||||
entryDate: string
|
||||
entryPrice: number
|
||||
exitDate: string
|
||||
exitPrice: number
|
||||
returnPct: number
|
||||
}
|
||||
export type WaveletTradeMarker = { date: string; type: 'buy' | 'sell'; price: number }
|
||||
|
||||
export const WAVELET_TRIGGER_KINDS: { value: WaveletTriggerKind; label: string }[] = [
|
||||
{ value: 'extremum', label: 'Extremum simple (pic/creux)' },
|
||||
{ value: 'trend_flatten', label: 'Tendance puis tassement' },
|
||||
{ value: 'acceleration', label: 'Deceleration/acceleration' },
|
||||
{ value: 'level_threshold', label: 'Seuil de niveau (sur/sous-achete)' },
|
||||
{ value: 'band_cross', label: 'Croisement de bandes' },
|
||||
{ value: 'ridge_shift', label: 'Changement de regime (ridge, ssq uniquement)' },
|
||||
{ value: 'energy_threshold', label: "Seuil d'energie (bande, ssq uniquement)" },
|
||||
]
|
||||
|
||||
export const WAVELET_BAND_COLORS = ['#f59e0b', '#14b8a6', '#8b5cf6', '#ef4444', '#0ea5e9', '#84cc16']
|
||||
|
||||
export function formatBandIndex(bandIndex: number): string {
|
||||
return bandIndex === -1 ? 'ridge' : `bande ${bandIndex}`
|
||||
}
|
||||
|
||||
export function defaultWaveletTrigger(curveId: string): WaveletTrigger {
|
||||
return {
|
||||
kind: 'extremum',
|
||||
curveId,
|
||||
secondaryCurveId: curveId,
|
||||
trendDays: 2,
|
||||
flattenDays: 4,
|
||||
trendThresholdK: 0,
|
||||
flattenThresholdK: 0.5,
|
||||
accelDays: 1,
|
||||
accelThresholdK: 0,
|
||||
levelThresholdK: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function computeSlope(series: number[]): number[] {
|
||||
const slope = new Array(series.length).fill(0)
|
||||
for (let i = 1; i < series.length; i++) slope[i] = series[i] - series[i - 1]
|
||||
if (series.length > 1) slope[0] = slope[1]
|
||||
return slope
|
||||
}
|
||||
|
||||
function computeAcceleration(slope: number[]): number[] {
|
||||
const accel = new Array(slope.length).fill(0)
|
||||
for (let i = 1; i < slope.length; i++) accel[i] = slope[i] - slope[i - 1]
|
||||
if (slope.length > 1) accel[0] = accel[1]
|
||||
return accel
|
||||
}
|
||||
|
||||
// Average of `slope` over the interval (from, to], i.e. the average daily change
|
||||
// between day `from` and day `to`. Used for both the "trend" window and the
|
||||
// "flatten" window of the trend_flatten trigger.
|
||||
function avgSlopeRange(slope: number[], from: number, to: number): number | null {
|
||||
if (from < 0 || to > slope.length - 1 || to <= from) return null
|
||||
let sum = 0
|
||||
for (let i = from + 1; i <= to; i++) sum += slope[i]
|
||||
return sum / (to - from)
|
||||
}
|
||||
|
||||
// Every build*Signal function returns a boolean array where signal[t] is already
|
||||
// causal-safe to act on exactly at day t (built only from data through day t) —
|
||||
// callers never need to know which trigger needs a lag.
|
||||
export function buildExtremumSignal(series: number[], direction: 'up' | 'down'): boolean[] {
|
||||
const n = series.length
|
||||
const raw = new Array(n).fill(false)
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
const prevSlope = series[i] - series[i - 1]
|
||||
const nextSlope = series[i + 1] - series[i]
|
||||
if (direction === 'up' && prevSlope > 0 && nextSlope <= 0) raw[i] = true // peak
|
||||
if (direction === 'down' && prevSlope < 0 && nextSlope >= 0) raw[i] = true // trough
|
||||
}
|
||||
// raw[j] needs series[j+1] to confirm, i.e. it's only knowable at day j+1 —
|
||||
// shift by one day so the signal itself never requires tomorrow's data.
|
||||
const shifted = new Array(n).fill(false)
|
||||
for (let i = 1; i < n; i++) shifted[i] = raw[i - 1]
|
||||
return shifted
|
||||
}
|
||||
|
||||
export function buildTrendFlattenSignal(
|
||||
series: number[], direction: 'up' | 'down',
|
||||
trendDays: number, flattenDays: number, trendThresholdK: number, flattenThresholdK: number,
|
||||
): boolean[] {
|
||||
const n = series.length
|
||||
const slope = computeSlope(series)
|
||||
const signal = new Array(n).fill(false)
|
||||
// Expanding (causal) std of the slope, accumulated only from data up to and
|
||||
// including day t — NOT a single std computed once over the whole series.
|
||||
let sum = 0, sumSq = 0
|
||||
for (let t = 1; t < n; t++) {
|
||||
sum += slope[t]; sumSq += slope[t] * slope[t]
|
||||
const count = t
|
||||
if (t < trendDays + flattenDays || count < 20) continue
|
||||
const mean = sum / count
|
||||
const variance = Math.max(0, sumSq / count - mean * mean)
|
||||
const std = Math.sqrt(variance)
|
||||
const trendThresh = trendThresholdK * std
|
||||
const flattenThresh = flattenThresholdK * std
|
||||
const trend = avgSlopeRange(slope, t - flattenDays - trendDays, t - flattenDays)
|
||||
const flat = avgSlopeRange(slope, t - flattenDays, t)
|
||||
if (trend === null || flat === null) continue
|
||||
if (direction === 'up' && trend > trendThresh && Math.abs(flat) <= flattenThresh) signal[t] = true
|
||||
if (direction === 'down' && trend < -trendThresh && Math.abs(flat) <= flattenThresh) signal[t] = true
|
||||
}
|
||||
return signal
|
||||
}
|
||||
|
||||
export function buildAccelerationSignal(series: number[], direction: 'up' | 'down', days: number, thresholdK: number): boolean[] {
|
||||
const n = series.length
|
||||
const slope = computeSlope(series)
|
||||
const accel = computeAcceleration(slope)
|
||||
const signal = new Array(n).fill(false)
|
||||
let sum = 0, sumSq = 0
|
||||
for (let t = 2; t < n; t++) {
|
||||
sum += accel[t]; sumSq += accel[t] * accel[t]
|
||||
const count = t - 1
|
||||
if (t < days || count < 20) continue
|
||||
const mean = sum / count
|
||||
const variance = Math.max(0, sumSq / count - mean * mean)
|
||||
const std = Math.sqrt(variance)
|
||||
const thresh = thresholdK * std
|
||||
if (direction === 'up') {
|
||||
if (slope[t] <= 0) continue
|
||||
let ok = true
|
||||
for (let d = 0; d < days; d++) if (!(accel[t - d] < -thresh)) { ok = false; break }
|
||||
signal[t] = ok
|
||||
} else {
|
||||
if (slope[t] >= 0) continue
|
||||
let ok = true
|
||||
for (let d = 0; d < days; d++) if (!(accel[t - d] > thresh)) { ok = false; break }
|
||||
signal[t] = ok
|
||||
}
|
||||
}
|
||||
return signal
|
||||
}
|
||||
|
||||
export function buildLevelThresholdSignal(series: number[], direction: 'up' | 'down', thresholdK: number): boolean[] {
|
||||
const n = series.length
|
||||
const signal = new Array(n).fill(false)
|
||||
let sum = 0, sumSq = 0
|
||||
for (let t = 0; t < n; t++) {
|
||||
sum += series[t]; sumSq += series[t] * series[t]
|
||||
const count = t + 1
|
||||
if (count < 20) continue // not enough history yet for a stable mean/std
|
||||
const mean = sum / count
|
||||
const variance = Math.max(0, sumSq / count - mean * mean)
|
||||
const std = Math.sqrt(variance)
|
||||
if (direction === 'up' && series[t] > mean + thresholdK * std) signal[t] = true
|
||||
if (direction === 'down' && series[t] < mean - thresholdK * std) signal[t] = true
|
||||
}
|
||||
return signal
|
||||
}
|
||||
|
||||
export function buildBandCrossSignal(primary: number[], secondary: number[], direction: 'up' | 'down'): boolean[] {
|
||||
const n = Math.min(primary.length, secondary.length)
|
||||
const signal = new Array(n).fill(false)
|
||||
for (let t = 1; t < n; t++) {
|
||||
const prevDiff = primary[t - 1] - secondary[t - 1]
|
||||
const currDiff = primary[t] - secondary[t]
|
||||
if (direction === 'down' && prevDiff >= 0 && currDiff < 0) signal[t] = true // crosses below (sell)
|
||||
if (direction === 'up' && prevDiff <= 0 && currDiff > 0) signal[t] = true // crosses above (buy)
|
||||
}
|
||||
return signal
|
||||
}
|
||||
|
||||
// Causal (expanding-window) mean of a series — only ever uses data up to and
|
||||
// including day t.
|
||||
function computeExpandingMean(series: number[]): number[] {
|
||||
const n = series.length
|
||||
const mean = new Array(n).fill(0)
|
||||
let sum = 0
|
||||
for (let t = 0; t < n; t++) { sum += series[t]; mean[t] = sum / (t + 1) }
|
||||
return mean
|
||||
}
|
||||
|
||||
// Only meaningful on a "ridge" curve (the synchrosqueezed dominant-cycle-period
|
||||
// track, method="ssq" only): fires when the dominant period crosses more than
|
||||
// thresholdK causal standard deviations away from its own expanding average.
|
||||
export function buildRidgeShiftSignal(periodSeries: number[], direction: 'up' | 'down', thresholdK: number): boolean[] {
|
||||
return buildLevelThresholdSignal(periodSeries, direction, thresholdK)
|
||||
}
|
||||
|
||||
export function buildTriggerSignal(trigger: WaveletTrigger, curves: WaveletCurve[], direction: 'up' | 'down', length: number): boolean[] {
|
||||
const curveById = new Map(curves.map((curve) => [curve.id, curve.series]))
|
||||
const series = curveById.get(trigger.curveId)
|
||||
if (!series) return new Array(length).fill(false)
|
||||
switch (trigger.kind) {
|
||||
case 'extremum': return buildExtremumSignal(series, direction)
|
||||
case 'trend_flatten': return buildTrendFlattenSignal(series, direction, trigger.trendDays, trigger.flattenDays, trigger.trendThresholdK, trigger.flattenThresholdK)
|
||||
case 'acceleration': return buildAccelerationSignal(series, direction, trigger.accelDays, trigger.accelThresholdK)
|
||||
case 'level_threshold': return buildLevelThresholdSignal(series, direction, trigger.levelThresholdK)
|
||||
case 'band_cross': return buildBandCrossSignal(series, curveById.get(trigger.secondaryCurveId) ?? series, direction)
|
||||
case 'ridge_shift': return buildRidgeShiftSignal(series, direction, trigger.levelThresholdK)
|
||||
case 'energy_threshold': return buildLevelThresholdSignal(series, direction, trigger.levelThresholdK)
|
||||
default: return new Array(length).fill(false)
|
||||
}
|
||||
}
|
||||
|
||||
export function runWaveletTradeSimulation(
|
||||
dates: string[], original: number[], curves: WaveletCurve[], config: WaveletTradeConfig,
|
||||
): {
|
||||
trades: WaveletTrade[]
|
||||
markers: WaveletTradeMarker[]
|
||||
totalReturnPct: number
|
||||
winRate: number
|
||||
openPosition: { direction: 'long' | 'short'; entryDate: string; entryPrice: number } | null
|
||||
} {
|
||||
const n = dates.length
|
||||
const slopeById = new Map(curves.map((curve) => [curve.id, computeSlope(curve.series)]))
|
||||
const aboveOwnMeanById = new Map(
|
||||
curves.map((curve) => {
|
||||
const mean = computeExpandingMean(curve.series)
|
||||
return [curve.id, curve.series.map((v, i) => v > mean[i])]
|
||||
}),
|
||||
)
|
||||
|
||||
const allowLong = config.mode !== 'short_only'
|
||||
const allowShort = config.mode !== 'long_only'
|
||||
const buySignal = allowLong ? buildTriggerSignal(config.buyTrigger, curves, 'down', n) : null
|
||||
const sellSignal = allowLong ? buildTriggerSignal(config.sellTrigger, curves, 'up', n) : null
|
||||
const shortSignal = allowShort ? buildTriggerSignal(config.shortTrigger, curves, 'up', n) : null
|
||||
const coverSignal = allowShort ? buildTriggerSignal(config.coverTrigger, curves, 'down', n) : null
|
||||
|
||||
const trades: WaveletTrade[] = []
|
||||
const markers: WaveletTradeMarker[] = []
|
||||
let position: 'flat' | 'long' | 'short' = 'flat'
|
||||
let entryDate = ''
|
||||
let entryPrice = 0
|
||||
|
||||
const filtersPass = (filters: WaveletSlopeFilter[], i: number) =>
|
||||
filters.every((filter) => {
|
||||
if (filter.kind === 'energy') {
|
||||
const above = aboveOwnMeanById.get(filter.curveId)?.[i] ?? false
|
||||
return filter.sign === 'positive' ? above : !above
|
||||
}
|
||||
const slope = slopeById.get(filter.curveId)?.[i] ?? 0
|
||||
return filter.sign === 'positive' ? slope > 0 : slope < 0
|
||||
})
|
||||
|
||||
const returnPct = (direction: 'long' | 'short', price: number) =>
|
||||
direction === 'long' ? ((price - entryPrice) / entryPrice) * 100 : ((entryPrice - price) / entryPrice) * 100
|
||||
|
||||
const pctExitHit = (direction: 'long' | 'short', price: number) => {
|
||||
const unrealized = returnPct(direction, price)
|
||||
if (config.takeProfitPct !== null && unrealized >= config.takeProfitPct) return true
|
||||
if (config.stopLossPct !== null && unrealized <= -config.stopLossPct) return true
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
// Check for closing the current position first, then check for opening a new
|
||||
// one — so a same-day "close short, open long" reversal isn't missed.
|
||||
const longSignalExit = position === 'long' && !!sellSignal?.[i] && filtersPass(config.sellFilters, i)
|
||||
const longPctExit = position === 'long' && config.exitStyle !== 'signal' && pctExitHit('long', original[i])
|
||||
const shortSignalExit = position === 'short' && !!coverSignal?.[i] && filtersPass(config.coverFilters, i)
|
||||
const shortPctExit = position === 'short' && config.exitStyle !== 'signal' && pctExitHit('short', original[i])
|
||||
|
||||
const longExits = config.exitStyle === 'pct' ? longPctExit : config.exitStyle === 'signal_or_pct' ? (longSignalExit || longPctExit) : longSignalExit
|
||||
const shortExits = config.exitStyle === 'pct' ? shortPctExit : config.exitStyle === 'signal_or_pct' ? (shortSignalExit || shortPctExit) : shortSignalExit
|
||||
|
||||
if (longExits) {
|
||||
const exitPrice = original[i]
|
||||
trades.push({ direction: 'long', entryDate, entryPrice, exitDate: dates[i], exitPrice, returnPct: returnPct('long', exitPrice) })
|
||||
markers.push({ date: dates[i], type: 'sell', price: exitPrice })
|
||||
position = 'flat'
|
||||
} else if (shortExits) {
|
||||
const exitPrice = original[i]
|
||||
trades.push({ direction: 'short', entryDate, entryPrice, exitDate: dates[i], exitPrice, returnPct: returnPct('short', exitPrice) })
|
||||
markers.push({ date: dates[i], type: 'buy', price: exitPrice })
|
||||
position = 'flat'
|
||||
}
|
||||
|
||||
if (position === 'flat') {
|
||||
// If both long and short entries qualify on the same day (only possible in
|
||||
// "both" mode), the long entry takes priority — arbitrary but deterministic.
|
||||
if (buySignal?.[i] && filtersPass(config.buyFilters, i)) {
|
||||
position = 'long'; entryDate = dates[i]; entryPrice = original[i]
|
||||
markers.push({ date: dates[i], type: 'buy', price: original[i] })
|
||||
} else if (shortSignal?.[i] && filtersPass(config.shortFilters, i)) {
|
||||
position = 'short'; entryDate = dates[i]; entryPrice = original[i]
|
||||
markers.push({ date: dates[i], type: 'sell', price: original[i] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalReturnPct = trades.reduce((sum, trade) => sum + trade.returnPct, 0)
|
||||
const wins = trades.filter((trade) => trade.returnPct > 0).length
|
||||
return {
|
||||
trades, markers, totalReturnPct,
|
||||
winRate: trades.length ? wins / trades.length : 0,
|
||||
openPosition: position !== 'flat' ? { direction: position, entryDate, entryPrice } : null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Optimization grid (multi-instrument batch search) ─────────────────────────
|
||||
|
||||
export type WaveletExitVariant = { exitStyle: WaveletExitStyle; takeProfitPct: number | null; stopLossPct: number | null }
|
||||
|
||||
export type WaveletOptimizationResult = {
|
||||
symbol: string
|
||||
lookback: number
|
||||
wavelet: string
|
||||
mode: WaveletPositionMode
|
||||
triggerKind: WaveletTriggerKind
|
||||
bandIndex: number
|
||||
exitStyle: WaveletExitStyle
|
||||
takeProfitPct: number | null
|
||||
stopLossPct: number | null
|
||||
totalReturnPct: number
|
||||
winRate: number
|
||||
tradeCount: number
|
||||
analysisMethod?: 'cwt' | 'ssq'
|
||||
}
|
||||
|
||||
// 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,
|
||||
): WaveletTradeConfig {
|
||||
const effectiveCurveId =
|
||||
triggerKind === 'ridge_shift' ? 'ridge' :
|
||||
triggerKind === 'energy_threshold' ? curveId.replace(/^band_/, 'energy_band_') :
|
||||
curveId
|
||||
const trigger = { ...defaultWaveletTrigger(effectiveCurveId), kind: triggerKind }
|
||||
// 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
|
||||
return {
|
||||
mode,
|
||||
buyTrigger: trigger, sellTrigger: trigger, buyFilters: [], sellFilters: [],
|
||||
shortTrigger: trigger, coverTrigger: trigger, shortFilters: [], coverFilters: [],
|
||||
exitStyle: variant.exitStyle, takeProfitPct: variant.takeProfitPct, stopLossPct: variant.stopLossPct,
|
||||
}
|
||||
}
|
||||
|
||||
export function runOptimizationGrid(
|
||||
symbol: string, lookback: number, wavelet: string, rolling: any,
|
||||
bandsToTest: number[], kindsToTest: WaveletTriggerKind[], modesToTest: WaveletPositionMode[],
|
||||
exitVariants: WaveletExitVariant[],
|
||||
): WaveletOptimizationResult[] {
|
||||
const curves: WaveletCurve[] = rolling.bands.map((band: any) => ({ id: `band_${band.index}`, label: band.label, series: band.series }))
|
||||
if (rolling.ridge_period_days) {
|
||||
curves.push({ id: 'ridge', label: 'Ridge (periode dominante, j)', series: rolling.ridge_period_days.map((v: number | null) => v ?? 0) })
|
||||
}
|
||||
rolling.bands.forEach((band: any) => {
|
||||
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 results: WaveletOptimizationResult[] = []
|
||||
let ridgeShiftComputed = false
|
||||
for (const bandIndex of bandsToTest) {
|
||||
if (!availableBandIndices.has(bandIndex)) continue
|
||||
const curveId = `band_${bandIndex}`
|
||||
for (const kind of kindsToTest) {
|
||||
if (kind === 'ridge_shift' && ridgeShiftComputed) continue
|
||||
for (const mode of modesToTest) {
|
||||
for (const variant of exitVariants) {
|
||||
const config = buildSymmetricTradeConfig(curveId, kind, mode, variant)
|
||||
const sim = runWaveletTradeSimulation(rolling.dates, rolling.original, curves, config)
|
||||
results.push({
|
||||
symbol, lookback, wavelet, mode,
|
||||
triggerKind: kind,
|
||||
bandIndex: kind === 'ridge_shift' ? -1 : bandIndex,
|
||||
exitStyle: variant.exitStyle, takeProfitPct: variant.takeProfitPct, stopLossPct: variant.stopLossPct,
|
||||
totalReturnPct: sim.totalReturnPct, winRate: sim.winRate, tradeCount: sim.trades.length,
|
||||
analysisMethod: rolling.method ?? 'cwt',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (kind === 'ridge_shift') ridgeShiftComputed = true
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Orchestrates the expensive part (one backend rolling-CWT fetch per instrument x
|
||||
// lookback x wavelet combo) sequentially — deliberately not parallel, so the
|
||||
// backend isn't hammered with concurrent CPU-heavy CWT requests. The cheap part
|
||||
// (grid of trigger configs per fetched series) runs entirely in-browser.
|
||||
export async function runFullOptimization(
|
||||
instruments: string[], lookbacks: number[], wavelets: string[], period: string, levels: number,
|
||||
bandsToTest: number[], kindsToTest: WaveletTriggerKind[], modesToTest: WaveletPositionMode[],
|
||||
exitVariants: WaveletExitVariant[],
|
||||
onProgress: (done: number, total: number, label: string, failures: string[]) => void,
|
||||
onBatchResults: (results: WaveletOptimizationResult[]) => void,
|
||||
method: 'cwt' | 'ssq' = 'cwt',
|
||||
): Promise<void> {
|
||||
const combos: { symbol: string; lookback: number; wavelet: string }[] = []
|
||||
for (const symbol of instruments) {
|
||||
for (const lookback of lookbacks) {
|
||||
for (const wavelet of wavelets) {
|
||||
combos.push({ symbol, lookback, wavelet })
|
||||
}
|
||||
}
|
||||
}
|
||||
const failures: string[] = []
|
||||
for (let i = 0; i < combos.length; i++) {
|
||||
const { symbol, lookback, wavelet } = combos[i]
|
||||
onProgress(i, combos.length, `${symbol} @ ${lookback}j (${wavelet})`, failures)
|
||||
try {
|
||||
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))
|
||||
} catch (error) {
|
||||
failures.push(`${symbol} @ ${lookback}j (${wavelet}): ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
onProgress(combos.length, combos.length, 'Termine', failures)
|
||||
}
|
||||
Reference in New Issue
Block a user