feat: Short-Seite im Runner + Walk-Forward-CLI (--shorts Flag)

- BacktestConfig.allowShort: boolean (required, explizit)
- Runner: Short-Entry (stop = close + mult×ATR), updateChandelierShort für Trail,
  Stop-Check auf High >= stop, Gap-Fill nach oben
- Bestehende Runner-Tests: allowShort: false ergänzt (Verhalten byte-identisch)
- Neuer E2E-Test: Short-Breakout → trailing_stop; Long-Only-Sanity-Check
- walkforward.ts script: --shorts Flag, Ausgabe "Shorts: AN/AUS"
- walkforward.test.ts: allowShort: false ergänzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:33:58 +00:00
parent 0e1b477e27
commit 736db184ab
4 changed files with 106 additions and 22 deletions

View File

@@ -41,7 +41,7 @@ test('Breakout → Entry auf 4h-Close, Crash → Stop-Exit auf 15m', () => {
const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]); const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]);
const result = runBacktest(data, { const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false,
}); });
expect(result.trades).toHaveLength(1); expect(result.trades).toHaveLength(1);
const t = result.trades[0]; const t = result.trades[0];
@@ -57,7 +57,7 @@ test('tradeFrom verhindert Entries im Warmup-Fenster', () => {
const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]); const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]);
const result = runBacktest(data, { const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 100 * H4, tradeTo: Number.MAX_SAFE_INTEGER, // nach Serien-Ende params: P, tradeFrom: 100 * H4, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false, // nach Serien-Ende
}); });
expect(result.trades).toHaveLength(0); expect(result.trades).toHaveLength(0);
}); });
@@ -73,7 +73,7 @@ test('Stop-Order ist ab Entry aktiv: Low der Entry-Candle unter Stop → soforti
const data = new Map<Pair, Candle[]>([['BTC_USDT', s]]); const data = new Map<Pair, Candle[]>([['BTC_USDT', s]]);
const result = runBacktest(data, { const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false,
}); });
expect(result.trades).toHaveLength(1); expect(result.trades).toHaveLength(1);
expect(result.trades[0].exitReason).toBe('trailing_stop'); expect(result.trades[0].exitReason).toBe('trailing_stop');
@@ -101,7 +101,7 @@ test('maxPositions: bei gleichzeitigen Signalen gewinnt die PAIRS-Reihenfolge',
]); ]);
const result = runBacktest(data, { const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 1, startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 1,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false,
}); });
// beide Pairs haben identische Serien → beide signalisieren; nur BTC (erster in PAIRS) darf // beide Pairs haben identische Serien → beide signalisieren; nur BTC (erster in PAIRS) darf
expect(result.trades).toHaveLength(1); expect(result.trades).toHaveLength(1);
@@ -112,7 +112,61 @@ test('Determinismus: identischer Input → identisches Ergebnis', () => {
const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]); const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]);
const cfg = { const cfg = {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false,
}; };
expect(JSON.stringify(runBacktest(data, cfg))).toBe(JSON.stringify(runBacktest(data, cfg))); expect(JSON.stringify(runBacktest(data, cfg))).toBe(JSON.stringify(runBacktest(data, cfg)));
}); });
/**
* Synthetische Short-Serie:
* - 7 Plateau-Buckets @ 100 (lows=100, highs=100): donchianLow=100, EMA5=100
* → close(100) NICHT < donchianLow(100) → kein Short-Signal auf dem Plateau
* - Breakdown-Bucket: close=85 < donchianLow(100) UND < EMA5(95) → Short-Signal
* - Rallye-Bucket: High 130 reißt den Short-Stop (≈85 + 1×ATR)
* - tradeTo = Breakdown-barCloseTs + H4: blockiert Long-Entry nach dem Short-Exit
*/
function shortSeries(): { candles: Candle[]; breakdownBarCloseTs: number } {
const s: Candle[] = [];
let b = 0;
// 7 Plateau-Buckets mit exakt flachen Candles (low=100=close=high)
for (let i = 0; i < 7; i++, b += H4) s.push(...flat4h(b, 100, 100, 100, 100));
const breakdownBucketStart = b;
// Breakdown: close=85 < donchianLow(100) AND < EMA5(~95)
s.push(...flat4h(b, 100, 100, 84, 85)); b += H4;
// Rallye: High 130 reißt Short-Stop (Stop ≈ 85+ATR)
s.push(...flat4h(b, 85, 130, 85, 120)); b += H4;
// Abschluss-Bucket (damit Rallye als abgeschlossen gilt)
s.push(...flat4h(b, 120, 121, 119, 120)); b += H4;
// tradeTo: Entry-Zeitpunkt des Breakdown = breakdownBucketStart + H4
// +H4 dahinter blockiert die Rallye von einem Long-Entry
return { candles: s, breakdownBarCloseTs: breakdownBucketStart + H4 };
}
test('Short-Breakout → Short-Entry auf 4h-Close, Rallye → Stop-Exit auf 15m', () => {
const { candles, breakdownBarCloseTs } = shortSeries();
// tradeTo = breakdownBarCloseTs + H4: Short-Entry (barCloseTs=breakdownBarCloseTs) ist erlaubt
// (breakdownBarCloseTs < tradeTo), aber der nächste Bar (barCloseTs=breakdownBarCloseTs+H4=tradeTo)
// ist blockiert → kein Long-Entry nach dem Short-Exit
const tradeTo = breakdownBarCloseTs + H4;
const data = new Map<Pair, Candle[]>([['BTC_USDT', candles]]);
const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 0, tradeTo, allowShort: true,
});
expect(result.trades).toHaveLength(1);
const t = result.trades[0];
// Entry: Short bei Close 85, Fill = 85*(1slippage) = 85*0.9995
expect(t.entryPrice).toBeCloseTo(85 * 0.9995);
expect(t.exitReason).toBe('trailing_stop');
expect(t.side).toBe('short');
// Verlustbringender Short (Preis stieg stark) → pnl < 0
expect(t.pnl).toBeLessThan(0);
// Sanity: gleiche Daten mit allowShort=false → kein Trade
// (kein Long-Signal: nach Plateau breakout close=85 ist weit unter EMA → blocked)
const resultLongOnly = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 0, tradeTo, allowShort: false,
});
expect(resultLongOnly.trades).toHaveLength(0);
});

View File

@@ -2,7 +2,7 @@ import type { Candle, Pair } from '../types';
import { PAIRS } from '../types'; import { PAIRS } from '../types';
import { aggregate4h, H4 } from '../market/aggregate'; import { aggregate4h, H4 } from '../market/aggregate';
import { computeIndicators, evaluateAt, type StrategyParams, type IndicatorSet } from '../strategy/donchian-trend'; import { computeIndicators, evaluateAt, type StrategyParams, type IndicatorSet } from '../strategy/donchian-trend';
import { updateChandelier } from '../strategy/chandelier'; import { updateChandelier, updateChandelierShort } from '../strategy/chandelier';
import { sizePosition, type RiskConfig } from '../engine/sizing'; import { sizePosition, type RiskConfig } from '../engine/sizing';
import { Portfolio, type ExecConfig, type ClosedTrade } from '../engine/portfolio'; import { Portfolio, type ExecConfig, type ClosedTrade } from '../engine/portfolio';
import type { EquityPoint } from './metrics'; import type { EquityPoint } from './metrics';
@@ -15,6 +15,7 @@ export interface BacktestConfig {
params: StrategyParams; params: StrategyParams;
tradeFrom: number; // ms inklusiv — Entries erst ab hier; Candles davor = Warmup tradeFrom: number; // ms inklusiv — Entries erst ab hier; Candles davor = Warmup
tradeTo: number; // ms exklusiv — danach wird zwangsglattgestellt tradeTo: number; // ms exklusiv — danach wird zwangsglattgestellt
allowShort: boolean;
} }
export interface BacktestResult { export interface BacktestResult {
@@ -69,15 +70,26 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
// 1a) Trailing-Stop der offenen Position nachziehen // 1a) Trailing-Stop der offenen Position nachziehen
const pos = portfolio.positions.get(pair); const pos = portfolio.positions.get(pair);
if (pos) { if (pos) {
if (pos.side === 'short') {
const next = updateChandelierShort(
{ lowestLow: pos.trailExtreme, stop: pos.stop },
bar.low,
ctx.ind.atr[i],
cfg.params.atrMultiplier,
);
pos.trailExtreme = next.lowestLow;
pos.stop = next.stop;
} else {
const next = updateChandelier( const next = updateChandelier(
{ highestHigh: pos.highestHigh, stop: pos.stop }, { highestHigh: pos.trailExtreme, stop: pos.stop },
bar.high, bar.high,
ctx.ind.atr[i], ctx.ind.atr[i],
cfg.params.atrMultiplier, cfg.params.atrMultiplier,
); );
pos.highestHigh = next.highestHigh; pos.trailExtreme = next.highestHigh;
pos.stop = next.stop; pos.stop = next.stop;
} }
}
// 1b) Entry-Evaluation // 1b) Entry-Evaluation
const barCloseTs = bar.ts + H4; const barCloseTs = bar.ts + H4;
@@ -87,12 +99,17 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
barCloseTs >= cfg.tradeFrom && barCloseTs >= cfg.tradeFrom &&
barCloseTs < cfg.tradeTo barCloseTs < cfg.tradeTo
) { ) {
const ev = evaluateAt(ctx.c4h, ctx.ind, i); const ev = evaluateAt(ctx.c4h, ctx.ind, i, cfg.allowShort);
if (ev.signal === 'long') { if (ev.signal === 'long') {
const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr; const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr;
const equity = portfolio.equity(lastClose); const equity = portfolio.equity(lastClose);
const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk); const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk, 'long');
if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount); if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount, 'long');
} else if (ev.signal === 'short') {
const initialStop = ev.close + cfg.params.atrMultiplier * ev.atr;
const equity = portfolio.equity(lastClose);
const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk, 'short');
if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount, 'short');
} }
} }
} }
@@ -102,10 +119,21 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
// ihre gesamte Range liegt nach dem Fill — eine echte Stop-Order wäre aktiv). // ihre gesamte Range liegt nach dem Fill — eine echte Stop-Order wäre aktiv).
// Pessimistisch-realistisch, nicht "wegoptimieren". // Pessimistisch-realistisch, nicht "wegoptimieren".
const pos = portfolio.positions.get(pair); const pos = portfolio.positions.get(pair);
if (pos && candle.low <= pos.stop) { if (pos) {
if (pos.side === 'short') {
// Short: Stop wird getriggert wenn High >= Stop (Deckungskauf)
if (candle.high >= pos.stop) {
const exitPrice = candle.open > pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill (höherer Preis)
portfolio.close(pair, ts, exitPrice, 'trailing_stop');
}
} else {
// Long: Stop wird getriggert wenn Low <= Stop
if (candle.low <= pos.stop) {
const exitPrice = candle.open < pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill const exitPrice = candle.open < pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill
portfolio.close(pair, ts, exitPrice, 'trailing_stop'); portfolio.close(pair, ts, exitPrice, 'trailing_stop');
} }
}
}
lastClose.set(pair, candle.close); lastClose.set(pair, candle.close);

View File

@@ -105,7 +105,7 @@ test('runWalkForward: OOS-Leak-Test mit synthetischen Daten', () => {
const result = runWalkForward( const result = runWalkForward(
data, data,
{ startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4 }, { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, allowShort: false },
dataFrom, dataFrom,
dataTo, dataTo,
); );

View File

@@ -19,7 +19,9 @@ for (const pair of PAIRS) {
console.log(`${pair}: ${cov.count} Candles (${cov.from.toISOString()}${cov.to.toISOString()})`); console.log(`${pair}: ${cov.count} Candles (${cov.from.toISOString()}${cov.to.toISOString()})`);
} }
const baseCfg = { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4 }; const allowShort = process.argv.includes('--shorts');
const baseCfg = { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, allowShort };
console.log(`Shorts: ${allowShort ? 'AN' : 'AUS'}`);
console.log(`\nWalk-Forward über ${((dataTo - dataFrom) / 86400000).toFixed(0)} Tage…\n`); console.log(`\nWalk-Forward über ${((dataTo - dataFrom) / 86400000).toFixed(0)} Tage…\n`);
const result = runWalkForward(candles15ByPair, baseCfg, dataFrom, dataTo, (msg) => console.log(msg)); const result = runWalkForward(candles15ByPair, baseCfg, dataFrom, dataTo, (msg) => console.log(msg));