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:
@@ -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 result = runBacktest(data, {
|
||||
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);
|
||||
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 result = runBacktest(data, {
|
||||
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);
|
||||
});
|
||||
@@ -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 result = runBacktest(data, {
|
||||
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[0].exitReason).toBe('trailing_stop');
|
||||
@@ -101,7 +101,7 @@ test('maxPositions: bei gleichzeitigen Signalen gewinnt die PAIRS-Reihenfolge',
|
||||
]);
|
||||
const result = runBacktest(data, {
|
||||
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
|
||||
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 cfg = {
|
||||
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)));
|
||||
});
|
||||
|
||||
/**
|
||||
* 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*(1−slippage) = 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);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Candle, Pair } from '../types';
|
||||
import { PAIRS } from '../types';
|
||||
import { aggregate4h, H4 } from '../market/aggregate';
|
||||
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 { Portfolio, type ExecConfig, type ClosedTrade } from '../engine/portfolio';
|
||||
import type { EquityPoint } from './metrics';
|
||||
@@ -15,6 +15,7 @@ export interface BacktestConfig {
|
||||
params: StrategyParams;
|
||||
tradeFrom: number; // ms inklusiv — Entries erst ab hier; Candles davor = Warmup
|
||||
tradeTo: number; // ms exklusiv — danach wird zwangsglattgestellt
|
||||
allowShort: boolean;
|
||||
}
|
||||
|
||||
export interface BacktestResult {
|
||||
@@ -69,15 +70,26 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
|
||||
// 1a) Trailing-Stop der offenen Position nachziehen
|
||||
const pos = portfolio.positions.get(pair);
|
||||
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(
|
||||
{ highestHigh: pos.highestHigh, stop: pos.stop },
|
||||
{ highestHigh: pos.trailExtreme, stop: pos.stop },
|
||||
bar.high,
|
||||
ctx.ind.atr[i],
|
||||
cfg.params.atrMultiplier,
|
||||
);
|
||||
pos.highestHigh = next.highestHigh;
|
||||
pos.trailExtreme = next.highestHigh;
|
||||
pos.stop = next.stop;
|
||||
}
|
||||
}
|
||||
|
||||
// 1b) Entry-Evaluation
|
||||
const barCloseTs = bar.ts + H4;
|
||||
@@ -87,12 +99,17 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
|
||||
barCloseTs >= cfg.tradeFrom &&
|
||||
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') {
|
||||
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);
|
||||
if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount);
|
||||
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, '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).
|
||||
// Pessimistisch-realistisch, nicht "wegoptimieren".
|
||||
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
|
||||
portfolio.close(pair, ts, exitPrice, 'trailing_stop');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastClose.set(pair, candle.close);
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ test('runWalkForward: OOS-Leak-Test mit synthetischen Daten', () => {
|
||||
|
||||
const result = runWalkForward(
|
||||
data,
|
||||
{ startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4 },
|
||||
{ startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, allowShort: false },
|
||||
dataFrom,
|
||||
dataTo,
|
||||
);
|
||||
|
||||
@@ -19,7 +19,9 @@ for (const pair of PAIRS) {
|
||||
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`);
|
||||
|
||||
const result = runWalkForward(candles15ByPair, baseCfg, dataFrom, dataTo, (msg) => console.log(msg));
|
||||
|
||||
Reference in New Issue
Block a user