test: Entry-Candle-Stop-Semantik + maxPositions-Determinismus festgenagelt, Grenzen dokumentiert

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:43:44 +00:00
parent 8ad1516665
commit 25a37f74db
2 changed files with 53 additions and 1 deletions

View File

@@ -62,6 +62,52 @@ test('tradeFrom verhindert Entries im Warmup-Fenster', () => {
expect(result.trades).toHaveLength(0); expect(result.trades).toHaveLength(0);
}); });
test('Stop-Order ist ab Entry aktiv: Low der Entry-Candle unter Stop → sofortiger Exit (pessimistisch)', () => {
const s: Candle[] = [];
let b = 0;
for (let i = 0; i < 7; i++, b += H4) s.push(...flat4h(b, 100, 101, 99, 100));
s.push(...flat4h(b, 100, 111, 100, 110)); b += H4; // Breakout, Entry ~110, ATR klein → Stop nahe
// Erste Candle des Folge-Buckets reißt mit Low 80 sofort den Stop
s.push(...flat4h(b, 110, 110, 80, 109)); b += H4;
s.push(...flat4h(b, 109, 110, 108, 109)); b += H4;
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,
});
expect(result.trades).toHaveLength(1);
expect(result.trades[0].exitReason).toBe('trailing_stop');
// Exit in derselben 4h-Periode wie der Entry
expect(result.trades[0].exitTs - result.trades[0].entryTs).toBeLessThan(H4);
});
test('maxPositions: bei gleichzeitigen Signalen gewinnt die PAIRS-Reihenfolge', () => {
// series() crasht im selben Bucket wie der Entry (gleicher ts) → BTC-Position wird
// bereits geschlossen, bevor ETH evaluiert wird, sodass ETH noch reinkommt.
// Daher eigene Serie: ein ruhiger Halte-Bucket nach dem Entry verhindert das.
function seriesWithHold(): Candle[] {
const s: Candle[] = [];
let b = 0;
for (let i = 0; i < 7; i++, b += H4) s.push(...flat4h(b, 100, 101, 99, 100));
s.push(...flat4h(b, 100, 111, 100, 110)); b += H4; // Breakout, Entry
s.push(...flat4h(b, 110, 112, 108, 111)); b += H4; // Halte-Bucket, kein Crash
s.push(...flat4h(b, 111, 111, 80, 85)); b += H4; // Crash
s.push(...flat4h(b, 85, 86, 84, 85)); b += H4;
return s;
}
const data = new Map<Pair, Candle[]>([
['BTC_USDT', seriesWithHold()],
['ETH_USDT', seriesWithHold()],
]);
const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 1,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER,
});
// beide Pairs haben identische Serien → beide signalisieren; nur BTC (erster in PAIRS) darf
expect(result.trades).toHaveLength(1);
expect(result.trades[0].pair).toBe('BTC_USDT');
});
test('Determinismus: identischer Input → identisches Ergebnis', () => { 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 = {

View File

@@ -58,6 +58,9 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
const ctx = byPair.get(pair)!; const ctx = byPair.get(pair)!;
const bucket = Math.floor(ts / H4) * H4; const bucket = Math.floor(ts / H4) * H4;
// Bekannte Grenze: 4h-Bars eines Pairs werden erst verarbeitet, wenn dessen
// nächste 15m-Candle eintrifft — bei Datenlücken eines Pairs verschiebt sich
// dessen Verarbeitung relativ zu anderen Pairs (betrifft maxPositions-Reihenfolge).
// 1) Neu abgeschlossene 4h-Candles dieses Pairs verarbeiten (alles < aktueller Bucket) // 1) Neu abgeschlossene 4h-Candles dieses Pairs verarbeiten (alles < aktueller Bucket)
while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) { while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) {
const i = ctx.next4h++; const i = ctx.next4h++;
@@ -94,7 +97,10 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
} }
} }
// 2) Stop-Check auf der 15m-Candle // 2) Stop-Check auf der 15m-Candle.
// Bewusst AUCH auf der Entry-Candle (Entry = Open-Zeitpunkt dieser Candle,
// ihre gesamte Range liegt nach dem Fill — eine echte Stop-Order wäre aktiv).
// Pessimistisch-realistisch, nicht "wegoptimieren".
const pos = portfolio.positions.get(pair); const pos = portfolio.positions.get(pair);
if (pos && candle.low <= pos.stop) { if (pos && 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