Skip to content

Instantly share code, notes, and snippets.

@ruvnet
Last active May 3, 2026 22:44
Show Gist options
  • Select an option

  • Save ruvnet/07f1571246910ac551fc0f91774045e2 to your computer and use it in GitHub Desktop.

Select an option

Save ruvnet/07f1571246910ac551fc0f91774045e2 to your computer and use it in GitHub Desktop.
MT5 Hammer/Shooting-Star Liquidity-Sweep EA — Ruflo tutorial (issue #1720)

MT5 Hammer / Shooting-Star Liquidity-Sweep EA — Ruflo Tutorial

What this is, in plain English

This repo turns a popular discretionary trading setup into an automated robot for MetaTrader 5, plus all the back-test and AI plumbing to actually trust it before you go live.

The trade idea (no jargon):

Price often makes a quick fake-out move just below a recent low (or just above a recent high) to trigger people's stop-losses, then snaps back in the other direction. If you can spot that fake-out as it happens — by recognising a candlestick that has a long thin tail in the direction of the fake-out (a hammer below, a shooting star above) — you have a high-probability, short-term reversal trade.

The robot in this repo:

  1. Watches every new bar on your chart for that exact pattern.
  2. Enters at the close of the fake-out bar.
  3. Stops out 1 pip past the tip of the wick (so the trade is wrong if price keeps going).
  4. Takes profit at either 1× or 2× the risk distance — your choice (this is the "1:1 or 1:2 RR" the issue asked for).
  5. Sizes the position so a stop-out only loses 1% of your account, regardless of how wide the stop is.
  6. Refuses to trade if the broader market regime says the trade direction is fighting the trend (this part is optional and uses ruflo's AI).

What you get in this repo:

File Plain-English purpose
ea/HammerStarSweep.mq5 The MetaTrader 5 robot. Drag it onto a chart and it trades for you.
bridge/strategy.mjs The same trading rules written in JavaScript so you can back-test them and prove they work before risking money.
bridge/backtest.mjs A command-line back-tester. Feed it historical price data, see how the strategy would have performed.
bridge/signal-bridge.mjs Optional: lets the smarter ruflo AI brain run alongside MT5, only sending trades that pass an extra "is this a sane trade right now?" check.
bridge/ruflo-integration.mjs The glue between this strategy and ruflo's memory + neural-trader + learning systems. Every trade gets remembered so the system gets smarter over time.
bridge/*.test.mjs 26 automated tests that prove the strategy logic is correct. Run these first.

Two ways to run it:

  • Just the robot — Drop the .mq5 file into MetaTrader, compile it, attach it to a chart. No Node, no ruflo, no internet needed. The trading logic is fully self-contained.
  • Robot + ruflo AI — Same robot, but a Node process runs alongside it that (a) checks the current market regime via neural-trader before letting any trade through, (b) saves every signal and back-test to ruflo's vector database for cross-session learning, and (c) feeds the win/loss outcomes back into ruflo's SONA neural network so future setups get better-quality entries.

What this is NOT:

  • ❌ Not a "set and forget money printer." Fixed-RR strategies have losing streaks. You need to back-test on your own data and demo-trade for at least 2 weeks.
  • ❌ Not financial advice. This is a reference implementation for a well-known technical setup. Trade your own account at your own risk.
  • ❌ Not a black box. Every rule is in plain code you can read, change, and verify. The MQL5 robot and the JavaScript back-tester apply exactly the same rules so what you back-test is what you trade.

Built for issue #1720. Reference implementation, MIT-licensed, educational use.


TL;DR

cd v3/docs/examples/mt5-hammer-star-sweep/bridge

# 1) Validate the strategy core (zero deps, pure Node 20+)
npm test

# 2) Backtest on the bundled sample data
node backtest.mjs --csv sample-data.csv --lookback 10 --rr 2

# 3) Run as a live signal bridge that the MT5 EA consumes
node signal-bridge.mjs --csv your-broker-feed.csv \
  --output ruflo_signals.json --interval 5000 --rr 2

Then attach ea/HammerStarSweep.mq5 to a chart in MetaTrader 5.


Why this exists

The issue asked for an MT5 EA built around two well-understood ideas:

  1. Liquidity sweeps — price spikes through a recent swing high or low, taking out stops, then closes back inside the prior range. The "trapped" stop orders create short-term mean-reversion fuel.
  2. Hammer / shooting-star confirmation — the sweep bar itself is a hammer (long lower wick, body in upper portion) for longs, or a shooting star (long upper wick, body in lower portion) for shorts.
  3. Fixed RR exits — 1:1 or 1:2, no trailing, no break-even. Easy to reason about.

This repo gives you three artefacts that all implement the same rules:

Artefact Role When you use it
bridge/strategy.mjs Pure-JS strategy core Backtesting, unit tests, signal generation
bridge/ruflo-integration.mjs Ruflo memory + neural-trader + intelligence wrapper Persist signals/backtests, regime gating, SONA learning
ea/HammerStarSweep.mq5 MQL5 Expert Advisor Direct execution inside MetaTrader 5
bridge/signal-bridge.mjs File-based bridge Run ruflo logic in Node, execute in MT5

How the ruflo integration is actually wired

This isn't a write-up that mentions ruflo — the code calls it. With --ruflo:

  1. memory init runs once per cwd (lazy, cached) so the AgentDB sql.js store is initialized.
  2. memory store (@claude-flow/cli) persists every emitted signal to namespace trading-signals and every backtest summary to trading-backtests, with 384-dim ONNX vector embeddings auto-generated.
  3. memory search queries prior backtests for the same symbol before running a new one — the bridge surfaces what previous runs found.
  4. neural-trader --signal scan is called via Ruflo.getRegime(symbol). Output is parsed into bull / bear / range / unknown and used to gate signals: longs are blocked in a bear regime, shorts are blocked in a bull regime.
  5. hooks post-task records the backtest's expectancy verdict so SONA can learn from this trajectory across sessions.

When --ruflo is omitted, every integration call returns { ok: false, reason: 'disabled' } and the strategy core runs unchanged. There is no runtime dependency on ruflo for the pure backtest.

Two operating modes for the EA:

  • Native (InpUseBridge=false): the EA does its own pattern detection in MQL5. No external dependencies. Use this if you don't want to run a Node process alongside MT5.
  • Bridge (InpUseBridge=true): the EA reads ruflo_signals.json written by signal-bridge.mjs. Use this when you want ruflo features (regime gating, neural prediction, memory persistence) on top of the bare strategy.

Both modes apply the same risk guardrails (min stop, fixed RR, 1% balance per trade).


Strategy specification

Long entry (hammer-sweep-low):

  1. Identify the lowest low of the previous N bars (default N=10).
  2. Current bar wicks below that low — current.low < prev_low.
  3. Current bar closes back above that low — current.close > prev_low.
  4. Current bar is a hammer:
    • lower wick / range ≥ 0.6
    • upper wick / range ≤ 0.1
    • body / range ≤ 0.4

If all four hold:

  • Entry: at the close of the sweep bar
  • Stop: 1 pip below the wick low
  • Target: entry + (entry − stop) × RR

Short entry (star-sweep-high) is the mirror: sweep above prior swing high, close back inside, shooting-star shape, stop above the wick.

Guardrails (apply to both directions):

  • Min stop distance: 5 pips by default (rejects razor-thin wick stops that won't survive normal spread + slippage)
  • Position size: risk_amount / stop_distance where risk_amount = balance × 1%
  • One position per symbol/magic at a time — no martingale, no overlapping trades

File layout

mt5-hammer-star-sweep/
├── README.md                       # this file
├── bridge/
│   ├── package.json                # zero deps, "test": "node --test"
│   ├── strategy.mjs                # pattern detection + backtest core
│   ├── strategy.test.mjs           # 14 unit + integration tests
│   ├── backtest.mjs                # CLI: node backtest.mjs --csv FILE
│   ├── signal-bridge.mjs           # CLI: writes ruflo_signals.json for the EA
│   └── sample-data.csv             # 40-bar synthetic feed (1 hammer + 1 star)
└── ea/
    └── HammerStarSweep.mq5         # MQL5 Expert Advisor (native + bridge modes)

Step 1 — Validate the strategy core

cd bridge
npm test

Expected:

# tests 14
# pass 14
# fail 0

The tests cover:

  • candle classification (hammer, shooting star, doji, normal trend bar)
  • sweep detection (low sweep, high sweep, no-sweep negative case)
  • signal generation at both 1R and 2R, including the min-stop guardrail
  • end-to-end backtest with both winning and losing outcomes
  • CSV parsing

If any test fails, stop. The strategy logic is broken; do not deploy.


Step 2 — Backtest

The included sample-data.csv is a 40-bar synthetic feed engineered to contain exactly one bullish hammer-sweep (around bar 11) and one bearish shooting-star sweep (around bar 31).

node backtest.mjs --csv sample-data.csv --lookback 10 --rr 2

Output:

bars       : 40
config     : lookback=10, rr=2, minStopPips=5, pip=0.0001
trades     : 1  (wins=1, losses=0)
win rate   : 100.00%
expectancy : 2.000 R per trade

last 5 trades:
  t=11 long  hammer-sweep-low -> win entry=1.10560 stop=1.10090 target=1.11500

(At --rr 1 you'll see both setups close — try it.)

For real backtests, supply your own CSV with the header time,open,high,low,close[,volume] exported from MetaTrader (File → Open Data Folder → MQL5/Files, then use a CSV-export utility script) or any vendor.

Acceptance criteria before going live:

  • ≥ 200 trades in the test set
  • expectancy > 0R per trade
  • max consecutive losses < 8
  • win-rate > 35% at RR=2 (≥ 60% at RR=1)

Step 3 — Run with ruflo integration enabled

Add --ruflo --symbol EURUSD to either tool. The Node process now actually calls the @claude-flow/cli and neural-trader CLIs.

# Backtest + auto-persist + record SONA trajectory:
npm run backtest:ruflo
# expands to:
node backtest.mjs --csv sample-data.csv --rr 2 --ruflo --symbol EURUSD --verbose

What happens under the hood (verified end-to-end):

[ruflo] memory init                           # lazy, runs once per cwd
[ruflo] searchPriorBacktests EURUSD ok        # surfaces prior runs from AgentDB
[ruflo] storeBacktest backtest-EURUSD-rr2-... ok
[ruflo] recordTrajectoryEnd ... win ok        # hooks post-task for SONA

Confirm the entry actually persisted:

npx @claude-flow/cli@latest memory retrieve \
  --key "backtest-EURUSD-rr2-$(date +%F)" \
  --namespace trading-backtests
# -> JSON summary, 84 bytes, with auto 384-dim vector embedding

For the live bridge:

npm run bridge:ruflo
# expands to:
node signal-bridge.mjs --csv sample-data.csv --output ruflo_signals.json \
  --interval 5000 --ruflo --symbol EURUSD --verbose

The bridge calls neural-trader --signal scan --symbol EURUSD once per candidate signal, classifies the output as bull / bear / range, and blocks the signal if direction conflicts with regime:

[bridge] gated: 11-long-hammer-sweep-low (long blocked in bear regime)

Each emitted signal is also stored in AgentDB namespace trading-signals so future ruflo sessions can recall the full signal history.

The liquidity-sweep skill in the ruflo-neural-trader plugin wraps these steps end-to-end:

# Inside Claude Code:
/liquidity-sweep --symbol EURUSD --rr 2

Step 4 — Live execution

Option A — Native MQL5 (no Node process)

  1. Copy ea/HammerStarSweep.mq5 into MetaTrader 5's MQL5/Experts/ folder.
  2. Open MetaEditor (F4 in MT5), open the file, press F7 to compile.
  3. In MT5, drag the EA onto a chart. Set inputs:
    • InpLookback = 10
    • InpRR = 2.0 (or 1.0)
    • InpRiskPercent = 1.0
    • InpMinStopPips = 5
    • InpUseBridge = false
  4. Allow live trading (the smiley face must be 😊 in the top right).
  5. Watch the Experts tab for log lines like BUY 0.10 @ 1.10560 sl=1.10090 tp=1.11500.

Option B — Ruflo bridge (Node + MT5)

  1. Set up a process that writes a tailing OHLCV CSV for your symbol (your broker probably has an export tool, or use MT5's iCustom to stream to a file via a small helper EA).
  2. Start the bridge:
    node bridge/signal-bridge.mjs \
      --csv /path/to/your/eurusd-m15.csv \
      --output /path/to/MT5/Common/Files/ruflo_signals.json \
      --interval 5000 --lookback 10 --rr 2
  3. In MT5, attach HammerStarSweep.mq5 with InpUseBridge = true and InpBridgePath = "ruflo_signals.json".
  4. The EA polls the file once per new bar; when a fresh signal id appears it places the order with the bridge-supplied SL/TP.

Common path for Common/Files:

  • Windows: C:\Users\<you>\AppData\Roaming\MetaQuotes\Terminal\Common\Files
  • macOS / Linux (under Wine): ~/.wine/drive_c/users/<you>/AppData/Roaming/MetaQuotes/Terminal/Common/Files

Validation checklist before going live

  • npm test is green (14/14)
  • Backtest on ≥ 6 months of M15 / M30 data shows positive expectancy
  • EA compiles in MetaEditor without warnings
  • Tested on a demo account for at least 2 weeks
  • Spread on your broker for the symbol < InpMinStopPips
  • Magic number is unique (default 17201) — no clashing EAs on the same account
  • You understand that fixed-RR strategies have inherent drawdown periods (consecutive losses are normal, not a bug)

Risks and known limitations

  1. Pattern false positives in strong trends — fix with regime gating (Step 3).
  2. Slippage on the sweep bar's close — use limit orders just inside the close instead of market orders if your broker allows it (modify g_trade.Buyg_trade.BuyLimit in the EA).
  3. News-driven sweeps — the strategy will happily trade the spike on an NFP release. Either disable the EA over high-impact news or add a calendar filter.
  4. Fixed RR ≠ optimal exits — this implementation matches the issue's spec. Trailing stops or scale-out logic would change the risk profile. A future variant lives in the liquidity-sweep plugin skill if you want to extend it.
  5. Bridge mode adds latency — at minimum one polling interval between bar close and order placement. Don't use sub-M5 timeframes with the bridge.

Reference

#!/usr/bin/env node
// CLI back-test runner. Reads OHLCV CSV, runs the strategy, prints stats.
//
// node backtest.mjs --csv sample-data.csv --lookback 10 --rr 2 --pip 0.0001
//
// CSV header (case-insensitive): time,open,high,low,close[,volume]
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { backtest, parseCsv } from './strategy.mjs';
import { Ruflo } from './ruflo-integration.mjs';
function parseArgs(argv) {
const out = {
csv: null, lookback: 10, rr: 2, minStopPips: 5, pipSize: 0.0001, json: false,
ruflo: false, symbol: 'EURUSD', verbose: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
const next = argv[i + 1];
if (a === '--csv') { out.csv = next; i++; }
else if (a === '--lookback') { out.lookback = parseInt(next, 10); i++; }
else if (a === '--rr') { out.rr = parseFloat(next); i++; }
else if (a === '--min-stop-pips') { out.minStopPips = parseInt(next, 10); i++; }
else if (a === '--pip') { out.pipSize = parseFloat(next); i++; }
else if (a === '--json') { out.json = true; }
else if (a === '--ruflo') { out.ruflo = true; }
else if (a === '--symbol') { out.symbol = next; i++; }
else if (a === '--verbose') { out.verbose = true; }
else if (a === '--help' || a === '-h') {
console.log(`Usage: node backtest.mjs --csv FILE [--lookback 10] [--rr 2] [--min-stop-pips 5] [--pip 0.0001] [--json] [--ruflo --symbol EURUSD]`);
process.exit(0);
}
}
return out;
}
function fmt(n, d = 4) {
return Number.isFinite(n) ? n.toFixed(d) : String(n);
}
async function main() {
const args = parseArgs(process.argv);
if (!args.csv) {
console.error('error: --csv FILE is required');
process.exit(2);
}
const text = readFileSync(resolve(args.csv), 'utf8');
const bars = parseCsv(text);
if (bars.length < args.lookback + 2) {
console.error(`error: need at least ${args.lookback + 2} bars, got ${bars.length}`);
process.exit(2);
}
const ruflo = new Ruflo({ enabled: args.ruflo, verbose: args.verbose });
// If ruflo is enabled, search for prior backtests on this symbol so we
// can warn the user about historical performance before running again.
if (args.ruflo) {
const prior = await ruflo.searchPriorBacktests(args.symbol);
if (prior.ok && prior.hits.length) {
console.log(`[ruflo] found ${prior.hits.length} prior backtest(s) for ${args.symbol}`);
for (const h of prior.hits.slice(0, 3)) console.log(` - ${h}`);
}
}
const result = backtest(bars, {
lookback: args.lookback,
rr: args.rr,
minStopPips: args.minStopPips,
pipSize: args.pipSize,
});
if (args.json) {
console.log(JSON.stringify(result, null, 2));
if (args.ruflo) await ruflo.storeBacktest(args.symbol, result);
return;
}
console.log(`bars : ${bars.length}`);
console.log(`config : lookback=${args.lookback}, rr=${args.rr}, minStopPips=${args.minStopPips}, pip=${args.pipSize}`);
console.log(`trades : ${result.total} (wins=${result.wins}, losses=${result.losses})`);
console.log(`win rate : ${fmt(result.winRate * 100, 2)}%`);
console.log(`expectancy : ${fmt(result.expectancyR, 3)} R per trade`);
if (result.trades.length) {
console.log('\nlast 5 trades:');
for (const t of result.trades.slice(-5)) {
console.log(
` t=${t.time} ${t.direction.padEnd(5)} ${t.pattern} -> ${t.result} ` +
`entry=${fmt(t.entry, 5)} stop=${fmt(t.stop, 5)} target=${fmt(t.target, 5)}`,
);
}
}
// Persist the backtest summary to AgentDB and record a trajectory verdict
// so SONA can learn from the run. No-op when --ruflo is omitted.
if (args.ruflo) {
const stored = await ruflo.storeBacktest(args.symbol, result);
if (stored.ok) console.log(`\n[ruflo] stored backtest -> namespace=trading-backtests key=${stored.key}`);
const verdict = result.expectancyR > 0 ? 'win' : 'loss';
const traceId = `backtest-${args.symbol}-rr${args.rr}-${Date.now()}`;
await ruflo.recordTrajectoryEnd(traceId, verdict, { expectancyR: result.expectancyR, total: result.total });
console.log(`[ruflo] trajectory recorded: ${traceId} verdict=${verdict}`);
}
}
main().catch((e) => { console.error(e); process.exit(1); });
//+------------------------------------------------------------------+
//| HammerStarSweep.mq5 |
//| Hammer / Shooting-Star Liquidity-Sweep EA |
//| |
//| Mirrors v3/docs/examples/mt5-hammer-star-sweep/bridge/strategy |
//| Two operating modes: |
//| 1) Native: detects sweeps + candle pattern in MQL5 |
//| 2) Bridge: reads ruflo_signals.json written by ruflo bridge |
//+------------------------------------------------------------------+
#property copyright "Ruflo - issue #1720"
#property link "https://github.com/ruvnet/ruflo"
#property version "1.00"
#property strict
#include <Trade/Trade.mqh>
input int InpLookback = 10; // Lookback bars for swing
input double InpRR = 2.0; // Reward:Risk (1.0 or 2.0)
input double InpRiskPercent = 1.0; // Risk per trade (% balance)
input int InpMinStopPips = 5; // Minimum stop distance
input int InpMagic = 17201; // Magic number
input bool InpUseBridge = false; // Use ruflo bridge file?
input string InpBridgePath = "ruflo_signals.json"; // File in Common/Files
CTrade g_trade;
datetime g_lastBarTime = 0;
//+------------------------------------------------------------------+
int OnInit()
{
g_trade.SetExpertMagicNumber(InpMagic);
g_trade.SetTypeFillingBySymbol(_Symbol);
PrintFormat("HammerStarSweep init: lookback=%d rr=%.1f risk=%.2f%% minStop=%d bridge=%s",
InpLookback, InpRR, InpRiskPercent, InpMinStopPips,
InpUseBridge ? "on" : "off");
return(INIT_SUCCEEDED);
}
void OnDeinit(const int reason) { }
//+------------------------------------------------------------------+
double Pip()
{
return ((_Digits == 3 || _Digits == 5) ? 10.0 * _Point : _Point);
}
bool IsNewBar()
{
datetime t = iTime(_Symbol, _Period, 0);
if(t == g_lastBarTime) return false;
g_lastBarTime = t;
return true;
}
bool HasOpenPosition()
{
for(int i = PositionsTotal() - 1; i >= 0; i--)
{
ulong ticket = PositionGetTicket(i);
if(!PositionSelectByTicket(ticket)) continue;
if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
if(PositionGetInteger(POSITION_MAGIC) != InpMagic) continue;
return true;
}
return false;
}
double LotsForRisk(double stopDistPrice)
{
double balance = AccountInfoDouble(ACCOUNT_BALANCE);
double riskAmount = balance * InpRiskPercent / 100.0;
double tickValue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
double tickSize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
double minLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
double maxLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);
double step = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
if(tickSize <= 0.0 || stopDistPrice <= 0.0 || tickValue <= 0.0) return minLot;
double valuePerPrice = tickValue / tickSize;
double lots = riskAmount / (stopDistPrice * valuePerPrice);
lots = MathMax(minLot, MathFloor(lots / step) * step);
lots = MathMin(maxLot, lots);
return lots;
}
//+------------------------------------------------------------------+
void OnTick()
{
if(!IsNewBar()) return;
if(InpUseBridge) { TryBridgeSignal(); return; }
TryNativeSignal();
}
//+------------------------------------------------------------------+
//| Native MQL5 detection — same rules as bridge/strategy.mjs |
//+------------------------------------------------------------------+
void TryNativeSignal()
{
if(HasOpenPosition()) return;
int needed = InpLookback + 2;
if(Bars(_Symbol, _Period) < needed) return;
// bar index 1 = last fully-closed bar
double openP = iOpen (_Symbol, _Period, 1);
double closeP = iClose(_Symbol, _Period, 1);
double highP = iHigh (_Symbol, _Period, 1);
double lowP = iLow (_Symbol, _Period, 1);
double prevHigh = -DBL_MAX;
double prevLow = DBL_MAX;
for(int i = 2; i <= InpLookback + 1; i++)
{
double h = iHigh(_Symbol, _Period, i);
double l = iLow (_Symbol, _Period, i);
if(h > prevHigh) prevHigh = h;
if(l < prevLow ) prevLow = l;
}
double body = MathAbs(closeP - openP);
double range = highP - lowP;
if(range <= 0.0 || body <= 0.0) return;
double upperWick = highP - MathMax(openP, closeP);
double lowerWick = MathMin(openP, closeP) - lowP;
// Range-relative thresholds (matches bridge/strategy.mjs)
bool isHammer = (lowerWick / range >= 0.6) && (upperWick / range <= 0.1) && (body / range <= 0.4);
bool isStar = (upperWick / range >= 0.6) && (lowerWick / range <= 0.1) && (body / range <= 0.4);
bool sweptLow = (lowP < prevLow ) && (closeP > prevLow );
bool sweptHigh = (highP > prevHigh) && (closeP < prevHigh);
double minStop = InpMinStopPips * Pip();
if(sweptLow && isHammer)
{
double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
double sl = lowP - Pip();
double stopDist = ask - sl;
if(stopDist < minStop) return;
double tp = ask + stopDist * InpRR;
double lots = LotsForRisk(stopDist);
if(g_trade.Buy(lots, _Symbol, ask, sl, tp, "HammerSweep"))
PrintFormat("BUY %.2f @ %.5f sl=%.5f tp=%.5f", lots, ask, sl, tp);
}
else if(sweptHigh && isStar)
{
double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
double sl = highP + Pip();
double stopDist = sl - bid;
if(stopDist < minStop) return;
double tp = bid - stopDist * InpRR;
double lots = LotsForRisk(stopDist);
if(g_trade.Sell(lots, _Symbol, bid, sl, tp, "StarSweep"))
PrintFormat("SELL %.2f @ %.5f sl=%.5f tp=%.5f", lots, bid, sl, tp);
}
}
//+------------------------------------------------------------------+
//| Bridge mode — consume signals written by ruflo signal-bridge.mjs |
//+------------------------------------------------------------------+
void TryBridgeSignal()
{
if(HasOpenPosition()) return;
int h = FileOpen(InpBridgePath, FILE_READ | FILE_TXT | FILE_COMMON);
if(h == INVALID_HANDLE) return;
string content = "";
while(!FileIsEnding(h)) content += FileReadString(h);
FileClose(h);
if(StringLen(content) == 0) return;
string id = ExtractField(content, "id");
string dir = ExtractField(content, "direction");
if(id == "" || dir == "") return;
// Skip if we've already acted on this id (stored in a global var)
string idGv = "HSS_LAST_ID";
string lastId = "";
if(GlobalVariableCheck(idGv))
{
// Strings can't be stored in GVs; we hash the id to a double
double prev = GlobalVariableGet(idGv);
double cur = (double)StringToInteger(id) + StringLen(id) * 0.0001;
if(MathAbs(prev - cur) < 1e-9) return;
GlobalVariableSet(idGv, cur);
}
else
{
double cur = (double)StringToInteger(id) + StringLen(id) * 0.0001;
GlobalVariableSet(idGv, cur);
}
double entry = StringToDouble(ExtractField(content, "entry"));
double sl = StringToDouble(ExtractField(content, "stop"));
double tp = StringToDouble(ExtractField(content, "target"));
double stopDist = MathAbs(entry - sl);
if(stopDist < InpMinStopPips * Pip()) return;
double lots = LotsForRisk(stopDist);
if(dir == "long")
{
if(g_trade.Buy(lots, _Symbol, 0, sl, tp, "BridgeLong"))
PrintFormat("BRIDGE BUY %.2f sl=%.5f tp=%.5f id=%s", lots, sl, tp, id);
}
else if(dir == "short")
{
if(g_trade.Sell(lots, _Symbol, 0, sl, tp, "BridgeShort"))
PrintFormat("BRIDGE SELL %.2f sl=%.5f tp=%.5f id=%s", lots, sl, tp, id);
}
}
//+------------------------------------------------------------------+
//| Tiny JSON field extractor — assumes flat object, no nesting |
//| Looks like: "field":VALUE (number, string, or unquoted token) |
//+------------------------------------------------------------------+
string ExtractField(string json, string field)
{
string key = "\"" + field + "\"";
int p = StringFind(json, key);
if(p < 0) return "";
p = StringFind(json, ":", p);
if(p < 0) return "";
p++;
while(p < StringLen(json))
{
ushort c = StringGetCharacter(json, p);
if(c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\"') { p++; continue; }
break;
}
int start = p;
while(p < StringLen(json))
{
ushort c = StringGetCharacter(json, p);
if(c == ',' || c == '}' || c == '\"' || c == '\n' || c == '\r') break;
p++;
}
return StringSubstr(json, start, p - start);
}
{
"name": "mt5-hammer-star-sweep-bridge",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Pure-JS strategy core, back-test, and MT5 file bridge for the hammer/shooting-star liquidity-sweep EA.",
"scripts": {
"test": "node --test strategy.test.mjs ruflo-integration.test.mjs",
"backtest": "node backtest.mjs --csv sample-data.csv --lookback 10 --rr 2",
"backtest:ruflo": "node backtest.mjs --csv sample-data.csv --lookback 10 --rr 2 --ruflo --symbol EURUSD --verbose",
"bridge": "node signal-bridge.mjs --csv sample-data.csv --output ruflo_signals.json --interval 5000",
"bridge:ruflo": "node signal-bridge.mjs --csv sample-data.csv --output ruflo_signals.json --interval 5000 --ruflo --symbol EURUSD --verbose"
},
"engines": {
"node": ">=20"
}
}
// ruflo-integration.mjs — actually call ruflo CLIs for memory, learning,
// and regime gating. All functions are async and degrade gracefully when
// the CLI is missing (returns null + logs a warning) so the strategy
// core stays usable in environments where ruflo isn't installed.
//
// Wraps three integration surfaces:
// 1. @claude-flow/cli memory — persist signals & backtest results
// 2. @claude-flow/cli hooks — record trajectories for SONA learning
// 3. neural-trader --signal — regime gating to filter false positives
//
// Usage: import { Ruflo } from './ruflo-integration.mjs';
// const r = new Ruflo({ enabled: true });
// await r.storeSignal(signal);
import { spawn } from 'node:child_process';
function runCli(cmd, args, { timeoutMs = 15000 } = {}) {
return new Promise((resolve) => {
let stdout = '';
let stderr = '';
let settled = false;
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { child.kill('SIGTERM'); } catch {}
resolve({ ok: false, code: -1, stdout, stderr: stderr + '\n[timeout]' });
}, timeoutMs);
child.stdout.on('data', (b) => { stdout += b.toString(); });
child.stderr.on('data', (b) => { stderr += b.toString(); });
child.on('error', (e) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ok: false, code: -1, stdout, stderr: e.message });
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ok: code === 0, code, stdout, stderr });
});
});
}
export class Ruflo {
constructor(opts = {}) {
this.enabled = opts.enabled !== false;
this.cliBin = opts.cliBin || 'npx';
this.cliPkg = opts.cliPkg || '@claude-flow/cli@latest';
this.traderBin = opts.traderBin || 'npx';
this.traderPkg = opts.traderPkg || 'neural-trader';
this.namespace = opts.namespace || 'trading-signals';
this.backtestNamespace = opts.backtestNamespace || 'trading-backtests';
this.verbose = opts.verbose === true;
this._initPromise = null;
}
log(...args) { if (this.verbose) console.log('[ruflo]', ...args); }
/**
* Lazy `memory init` — the CLI requires this once per cwd before
* store/list will round-trip. Cached so we only run it once per process.
*/
async ensureInit() {
if (!this.enabled) return { ok: false, reason: 'disabled' };
if (this._initPromise) return this._initPromise;
this._initPromise = (async () => {
const res = await runCli(this.cliBin, ['-y', this.cliPkg, 'memory', 'init'], { timeoutMs: 30000 });
this.log('memory init', res.ok ? 'ok' : `failed (${res.code})`);
return { ok: res.ok };
})();
return this._initPromise;
}
/** Store a signal in AgentDB via the @claude-flow/cli memory subcommand. */
async storeSignal(signal, key) {
if (!this.enabled) return { ok: false, reason: 'disabled' };
await this.ensureInit();
const k = key || `signal-${signal.time}-${signal.direction}-${signal.pattern}`;
const value = JSON.stringify(signal);
const tags = `pattern:${signal.pattern},rr:${signal.rr},dir:${signal.direction}`;
const res = await runCli(this.cliBin, [
'-y', this.cliPkg,
'memory', 'store',
'--key', k,
'--value', value,
'--namespace', this.namespace,
'--tags', tags,
]);
this.log('storeSignal', k, res.ok ? 'ok' : `failed (${res.code})`);
return { ok: res.ok, key: k, stdout: res.stdout, stderr: res.stderr };
}
/** Store backtest summary so future sessions can learn from this run. */
async storeBacktest(symbol, result, opts = {}) {
if (!this.enabled) return { ok: false, reason: 'disabled' };
await this.ensureInit();
const date = opts.date || new Date().toISOString().slice(0, 10);
const key = `backtest-${symbol}-rr${result.rr}-${date}`;
const summary = {
symbol,
rr: result.rr,
total: result.total,
wins: result.wins,
losses: result.losses,
winRate: result.winRate,
expectancyR: result.expectancyR,
};
const res = await runCli(this.cliBin, [
'-y', this.cliPkg,
'memory', 'store',
'--key', key,
'--value', JSON.stringify(summary),
'--namespace', this.backtestNamespace,
'--tags', `symbol:${symbol},rr:${result.rr}`,
]);
this.log('storeBacktest', key, res.ok ? 'ok' : `failed (${res.code})`);
return { ok: res.ok, key, summary };
}
/** Search prior backtests for a symbol so the bridge can warn on a poor history. */
async searchPriorBacktests(symbol, limit = 5) {
if (!this.enabled) return { ok: false, reason: 'disabled', hits: [] };
await this.ensureInit();
const res = await runCli(this.cliBin, [
'-y', this.cliPkg,
'memory', 'search',
'--query', `hammer-sweep ${symbol} expectancy`,
'--namespace', this.backtestNamespace,
'--limit', String(limit),
]);
this.log('searchPriorBacktests', symbol, res.ok ? 'ok' : `failed (${res.code})`);
return { ok: res.ok, raw: res.stdout, hits: parseHits(res.stdout) };
}
/**
* Record a trajectory step so SONA can learn from this signal lifecycle.
* Called at: trajectory-start (entry), trajectory-step (each bar held),
* trajectory-end (exit with verdict).
*/
async recordTrajectoryEnd(traceId, verdict, payload = {}) {
if (!this.enabled) return { ok: false, reason: 'disabled' };
const res = await runCli(this.cliBin, [
'-y', this.cliPkg,
'hooks', 'post-task',
'--task-id', traceId,
'--success', verdict === 'win' ? 'true' : 'false',
'--store-results', 'true',
]);
this.log('recordTrajectoryEnd', traceId, verdict, res.ok ? 'ok' : `failed`);
return { ok: res.ok, traceId, verdict, payload };
}
/**
* Ask neural-trader for the current regime so we can gate signals.
* Returns one of: 'bull' | 'bear' | 'range' | 'unknown'.
*/
async getRegime(symbol) {
if (!this.enabled) return 'unknown';
const res = await runCli(this.traderBin, [
'-y', this.traderPkg,
'--signal', 'scan',
'--symbol', symbol,
], { timeoutMs: 30000 });
if (!res.ok) {
this.log('getRegime', symbol, 'failed', res.code);
return 'unknown';
}
return classifyRegime(res.stdout);
}
/**
* Combined gate used by the bridge: should we emit this signal?
* Returns { allow, reason }.
*/
async gateSignal(signal, symbol) {
if (!this.enabled) return { allow: true, reason: 'ruflo disabled' };
const regime = await this.getRegime(symbol);
if (regime === 'unknown') return { allow: true, reason: 'regime unknown — pass-through' };
if (signal.direction === 'long' && regime === 'bear') {
return { allow: false, reason: `long blocked in ${regime} regime` };
}
if (signal.direction === 'short' && regime === 'bull') {
return { allow: false, reason: `short blocked in ${regime} regime` };
}
return { allow: true, reason: `regime=${regime} compatible with ${signal.direction}` };
}
}
// --- helpers --------------------------------------------------------------
function classifyRegime(text) {
const t = text.toLowerCase();
if (/(bull|uptrend|drift\s*up|momentum.*up)/.test(t)) return 'bull';
if (/(bear|downtrend|drift\s*down|momentum.*down)/.test(t)) return 'bear';
if (/(flatline|consolidat|range|oscillation)/.test(t)) return 'range';
return 'unknown';
}
function parseHits(stdout) {
// best-effort: pick out lines that look like "key=... value=..." or JSON blobs
const lines = stdout.split(/\r?\n/).filter((l) => l.trim().length);
return lines.slice(0, 20).map((l) => l.trim());
}
// --- exported for tests ---------------------------------------------------
export const __testables = { runCli, classifyRegime, parseHits };
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Ruflo, __testables } from './ruflo-integration.mjs';
const { classifyRegime, parseHits } = __testables;
test('classifyRegime: bullish keywords map to bull', () => {
assert.equal(classifyRegime('strong uptrend with momentum up'), 'bull');
assert.equal(classifyRegime('drift up with rising volume'), 'bull');
});
test('classifyRegime: bearish keywords map to bear', () => {
assert.equal(classifyRegime('downtrend confirmed'), 'bear');
assert.equal(classifyRegime('momentum down on multiple timeframes'), 'bear');
});
test('classifyRegime: range keywords map to range', () => {
assert.equal(classifyRegime('consolidation tightening'), 'range');
assert.equal(classifyRegime('oscillation between supports'), 'range');
assert.equal(classifyRegime('flatline detected'), 'range');
});
test('classifyRegime: ambiguous text -> unknown', () => {
assert.equal(classifyRegime('no clear signal'), 'unknown');
assert.equal(classifyRegime(''), 'unknown');
});
test('parseHits: returns trimmed non-empty lines', () => {
const out = ' line1 \n\nline2\n \nline3\n';
const hits = parseHits(out);
assert.deepEqual(hits, ['line1', 'line2', 'line3']);
});
test('Ruflo.gateSignal: passes through when disabled', async () => {
const r = new Ruflo({ enabled: false });
const decision = await r.gateSignal(
{ direction: 'long', pattern: 'hammer-sweep-low', rr: 2 },
'EURUSD',
);
assert.equal(decision.allow, true);
assert.match(decision.reason, /disabled/);
});
test('Ruflo.gateSignal: blocks long in bear regime', async () => {
const r = new Ruflo({ enabled: true });
// Stub getRegime so we don't shell out
r.getRegime = async () => 'bear';
const decision = await r.gateSignal(
{ direction: 'long', pattern: 'hammer-sweep-low', rr: 2 },
'EURUSD',
);
assert.equal(decision.allow, false);
assert.match(decision.reason, /bear/);
});
test('Ruflo.gateSignal: blocks short in bull regime', async () => {
const r = new Ruflo({ enabled: true });
r.getRegime = async () => 'bull';
const decision = await r.gateSignal(
{ direction: 'short', pattern: 'star-sweep-high', rr: 2 },
'EURUSD',
);
assert.equal(decision.allow, false);
assert.match(decision.reason, /bull/);
});
test('Ruflo.gateSignal: allows long in range regime', async () => {
const r = new Ruflo({ enabled: true });
r.getRegime = async () => 'range';
const decision = await r.gateSignal(
{ direction: 'long', pattern: 'hammer-sweep-low', rr: 2 },
'EURUSD',
);
assert.equal(decision.allow, true);
assert.match(decision.reason, /range/);
});
test('Ruflo.gateSignal: allows when regime unknown (pass-through)', async () => {
const r = new Ruflo({ enabled: true });
r.getRegime = async () => 'unknown';
const decision = await r.gateSignal(
{ direction: 'long', pattern: 'hammer-sweep-low', rr: 2 },
'EURUSD',
);
assert.equal(decision.allow, true);
});
test('Ruflo.storeSignal: returns ok=false when disabled (no shell call)', async () => {
const r = new Ruflo({ enabled: false });
const res = await r.storeSignal({ time: 1, direction: 'long', pattern: 'x', rr: 2 });
assert.equal(res.ok, false);
assert.equal(res.reason, 'disabled');
});
test('Ruflo.storeBacktest: returns ok=false when disabled (no shell call)', async () => {
const r = new Ruflo({ enabled: false });
const res = await r.storeBacktest('EURUSD', {
rr: 2, total: 10, wins: 6, losses: 4, winRate: 0.6, expectancyR: 0.4,
});
assert.equal(res.ok, false);
});
time open high low close volume
1 1.10500 1.10560 1.10450 1.10520 100
2 1.10520 1.10570 1.10470 1.10540 110
3 1.10540 1.10580 1.10480 1.10500 120
4 1.10500 1.10560 1.10460 1.10510 115
5 1.10510 1.10550 1.10470 1.10530 108
6 1.10530 1.10570 1.10480 1.10520 112
7 1.10520 1.10560 1.10470 1.10500 116
8 1.10500 1.10540 1.10460 1.10520 109
9 1.10520 1.10550 1.10470 1.10530 114
10 1.10530 1.10560 1.10480 1.10520 107
11 1.10520 1.10570 1.10100 1.10560 250
12 1.10560 1.10620 1.10540 1.10610 140
13 1.10610 1.10680 1.10580 1.10670 150
14 1.10670 1.10760 1.10650 1.10750 160
15 1.10750 1.10860 1.10740 1.10840 170
16 1.10840 1.10960 1.10820 1.10940 180
17 1.10940 1.11080 1.10920 1.11060 190
18 1.11060 1.11200 1.11040 1.11180 200
19 1.11180 1.11340 1.11160 1.11320 210
20 1.11320 1.11500 1.11300 1.11480 220
21 1.11480 1.11620 1.11460 1.11580 205
22 1.11580 1.11540 1.11500 1.11520 150
23 1.11520 1.11560 1.11490 1.11510 140
24 1.11510 1.11530 1.11470 1.11490 135
25 1.11490 1.11500 1.11440 1.11460 130
26 1.11460 1.11480 1.11410 1.11430 128
27 1.11430 1.11440 1.11380 1.11400 125
28 1.11400 1.11420 1.11370 1.11390 122
29 1.11390 1.11410 1.11360 1.11380 120
30 1.11380 1.11400 1.11350 1.11370 118
31 1.11370 1.12000 1.11360 1.11400 400
32 1.11400 1.11380 1.11260 1.11300 180
33 1.11300 1.11320 1.11200 1.11240 170
34 1.11240 1.11260 1.11140 1.11180 165
35 1.11180 1.11200 1.11080 1.11120 160
36 1.11120 1.11140 1.11020 1.11060 155
37 1.11060 1.11080 1.10960 1.11000 150
38 1.11000 1.11020 1.10900 1.10940 145
39 1.10940 1.10960 1.10840 1.10880 140
40 1.10880 1.10900 1.10780 1.10820 135
#!/usr/bin/env node
// signal-bridge.mjs — file-based bridge between ruflo (Node) and MT5.
//
// Reads a streaming OHLCV CSV (or polls a file the broker writes), runs the
// strategy on every new bar, and writes the latest signal as JSON to a file
// the MT5 EA reads. Intentionally minimal: no sockets, no DLLs, runs anywhere.
//
// The MT5 EA `HammerStarSweep.mq5` is configured with:
// InpUseBridge = true
// InpBridgePath = "ruflo_signals.json" (in <Terminal>/Common/Files/)
//
// Usage:
// node signal-bridge.mjs --csv live-feed.csv --output ruflo_signals.json \
// --interval 5000 --lookback 10 --rr 2
import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
import { generateSignal, parseCsv } from './strategy.mjs';
import { Ruflo } from './ruflo-integration.mjs';
function parseArgs(argv) {
const out = {
csv: null,
output: 'ruflo_signals.json',
interval: 5000,
lookback: 10,
rr: 2,
minStopPips: 5,
pipSize: 0.0001,
once: false,
ruflo: false,
symbol: 'EURUSD',
verbose: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
const next = argv[i + 1];
if (a === '--csv') { out.csv = next; i++; }
else if (a === '--output') { out.output = next; i++; }
else if (a === '--interval') { out.interval = parseInt(next, 10); i++; }
else if (a === '--lookback') { out.lookback = parseInt(next, 10); i++; }
else if (a === '--rr') { out.rr = parseFloat(next); i++; }
else if (a === '--min-stop-pips') { out.minStopPips = parseInt(next, 10); i++; }
else if (a === '--pip') { out.pipSize = parseFloat(next); i++; }
else if (a === '--once') { out.once = true; }
else if (a === '--ruflo') { out.ruflo = true; }
else if (a === '--symbol') { out.symbol = next; i++; }
else if (a === '--verbose') { out.verbose = true; }
else if (a === '--help' || a === '-h') {
console.log(`Usage: node signal-bridge.mjs --csv FILE --output FILE [--interval ms] [--lookback N] [--rr N] [--once] [--ruflo --symbol EURUSD]`);
process.exit(0);
}
}
return out;
}
let lastMtime = 0;
let lastSignalId = '';
async function tick(args, ruflo) {
if (!existsSync(args.csv)) return;
const stat = statSync(args.csv);
if (stat.mtimeMs === lastMtime && !args.once) return;
lastMtime = stat.mtimeMs;
const bars = parseCsv(readFileSync(args.csv, 'utf8'));
if (bars.length < args.lookback + 1) return;
const sig = generateSignal(bars, {
lookback: args.lookback,
rr: args.rr,
minStopPips: args.minStopPips,
pipSize: args.pipSize,
});
if (!sig) return;
const id = `${sig.time}-${sig.direction}-${sig.pattern}`;
if (id === lastSignalId) return;
lastSignalId = id;
// Ruflo regime gate — skip the signal if neural-trader says the regime
// disagrees with the trade direction. When --ruflo is off this is a no-op.
const gate = await ruflo.gateSignal(sig, args.symbol);
if (!gate.allow) {
console.log(`[bridge] gated: ${id} (${gate.reason})`);
return;
}
const payload = {
id,
direction: sig.direction,
entry: sig.entry,
stop: sig.stop,
target: sig.target,
pattern: sig.pattern,
rr: sig.rr,
sweepLevel: sig.sweepLevel,
symbol: args.symbol,
gate: gate.reason,
issuedAt: new Date().toISOString(),
};
writeFileSync(resolve(args.output), JSON.stringify(payload, null, 2));
console.log(`[bridge] signal: ${id} -> ${args.output} (${gate.reason})`);
// Persist the signal so future ruflo sessions can recall and learn.
// Async + best-effort — failures don't block trading.
ruflo.storeSignal(payload).then((r) => {
if (r.ok) console.log(`[bridge] memory store: ${r.key}`);
}).catch(() => {});
}
async function main() {
const args = parseArgs(process.argv);
if (!args.csv) {
console.error('error: --csv FILE is required (the OHLCV feed to poll)');
process.exit(2);
}
const ruflo = new Ruflo({
enabled: args.ruflo,
namespace: 'trading-signals',
verbose: args.verbose,
});
if (args.once) {
await tick(args, ruflo);
return;
}
console.log(`[bridge] polling ${args.csv} every ${args.interval}ms -> ${args.output} (ruflo=${args.ruflo})`);
await tick(args, ruflo);
setInterval(() => { tick(args, ruflo).catch((e) => console.error('[bridge]', e)); }, args.interval);
}
main();
// Hammer / Shooting-Star Liquidity Sweep — pure-JS strategy core.
// Used by the bridge, the backtest harness, and the unit tests.
// Mirrors the MQL5 logic in ../ea/HammerStarSweep.mq5 so the EA and
// the ruflo bridge can share one source of truth.
const DEFAULTS = {
lookback: 10, // bars used to define the prior swing
rr: 2, // reward:risk multiplier (1 or 2 per the issue)
minStopPips: 5, // guardrail against razor-thin wick stops
pipSize: 0.0001, // EURUSD-style 5-digit pip; override per symbol
};
export function classifyCandle(bar) {
const range = bar.high - bar.low;
if (range <= 0) return 'flat';
const body = Math.abs(bar.close - bar.open);
const upperWick = bar.high - Math.max(bar.open, bar.close);
const lowerWick = Math.min(bar.open, bar.close) - bar.low;
if (body <= 0) return 'doji';
// Range-relative thresholds — robust when the body is tiny (the
// body*N rule breaks down at extreme body sizes).
const isHammer =
lowerWick / range >= 0.6 && upperWick / range <= 0.1 && body / range <= 0.4;
const isStar =
upperWick / range >= 0.6 && lowerWick / range <= 0.1 && body / range <= 0.4;
if (isHammer) return 'hammer';
if (isStar) return 'shooting_star';
return 'other';
}
export function detectSweep(bars, lookback = DEFAULTS.lookback) {
if (bars.length < lookback + 1) return null;
const cur = bars[bars.length - 1];
const window = bars.slice(-lookback - 1, -1);
let prevHigh = -Infinity;
let prevLow = Infinity;
for (const b of window) {
if (b.high > prevHigh) prevHigh = b.high;
if (b.low < prevLow) prevLow = b.low;
}
if (cur.low < prevLow && cur.close > prevLow) {
return { type: 'low', level: prevLow };
}
if (cur.high > prevHigh && cur.close < prevHigh) {
return { type: 'high', level: prevHigh };
}
return null;
}
export function generateSignal(bars, opts = {}) {
const cfg = { ...DEFAULTS, ...opts };
const sweep = detectSweep(bars, cfg.lookback);
if (!sweep) return null;
const cur = bars[bars.length - 1];
const candle = classifyCandle(cur);
const minStop = cfg.minStopPips * cfg.pipSize;
if (sweep.type === 'low' && candle === 'hammer') {
const stop = cur.low - cfg.pipSize;
const stopDist = cur.close - stop;
if (stopDist < minStop) return null;
return {
direction: 'long',
entry: cur.close,
stop,
target: cur.close + stopDist * cfg.rr,
stopDist,
pattern: 'hammer-sweep-low',
sweepLevel: sweep.level,
time: cur.time,
rr: cfg.rr,
};
}
if (sweep.type === 'high' && candle === 'shooting_star') {
const stop = cur.high + cfg.pipSize;
const stopDist = stop - cur.close;
if (stopDist < minStop) return null;
return {
direction: 'short',
entry: cur.close,
stop,
target: cur.close - stopDist * cfg.rr,
stopDist,
pattern: 'star-sweep-high',
sweepLevel: sweep.level,
time: cur.time,
rr: cfg.rr,
};
}
return null;
}
// Back-test simulator. Walks the bars chronologically, opens at most one
// position at a time, and resolves each trade by checking subsequent
// bars for stop/target hits (intra-bar order is unknown — assumes the
// pessimistic-for-the-trade outcome if both happen on the same bar).
export function backtest(bars, opts = {}) {
const cfg = { ...DEFAULTS, ...opts };
const trades = [];
let i = cfg.lookback;
while (i < bars.length - 1) {
const window = bars.slice(0, i + 1);
const sig = generateSignal(window, cfg);
if (!sig) {
i++;
continue;
}
let outcome = null;
for (let j = i + 1; j < bars.length; j++) {
const b = bars[j];
if (sig.direction === 'long') {
const hitStop = b.low <= sig.stop;
const hitTarget = b.high >= sig.target;
if (hitStop && hitTarget) {
outcome = { result: 'loss', exitTime: b.time, exitPrice: sig.stop };
} else if (hitStop) {
outcome = { result: 'loss', exitTime: b.time, exitPrice: sig.stop };
} else if (hitTarget) {
outcome = { result: 'win', exitTime: b.time, exitPrice: sig.target };
}
} else {
const hitStop = b.high >= sig.stop;
const hitTarget = b.low <= sig.target;
if (hitStop && hitTarget) {
outcome = { result: 'loss', exitTime: b.time, exitPrice: sig.stop };
} else if (hitStop) {
outcome = { result: 'loss', exitTime: b.time, exitPrice: sig.stop };
} else if (hitTarget) {
outcome = { result: 'win', exitTime: b.time, exitPrice: sig.target };
}
}
if (outcome) {
outcome.exitIndex = j;
break;
}
}
if (outcome) {
trades.push({ ...sig, ...outcome });
i = outcome.exitIndex + 1;
} else {
i++;
}
}
const wins = trades.filter((t) => t.result === 'win').length;
const losses = trades.filter((t) => t.result === 'loss').length;
const total = trades.length;
const winRate = total ? wins / total : 0;
const expectancyR = total ? (wins * cfg.rr - losses) / total : 0;
return { trades, wins, losses, total, winRate, expectancyR, rr: cfg.rr };
}
export function parseCsv(text) {
const lines = text.trim().split(/\r?\n/);
const header = lines[0].split(',').map((s) => s.trim().toLowerCase());
const idx = (k) => header.indexOf(k);
const tIdx = idx('time') >= 0 ? idx('time') : idx('timestamp');
const out = [];
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',');
if (cols.length < 5) continue;
out.push({
time: tIdx >= 0 ? cols[tIdx].trim() : i,
open: parseFloat(cols[idx('open')]),
high: parseFloat(cols[idx('high')]),
low: parseFloat(cols[idx('low')]),
close: parseFloat(cols[idx('close')]),
volume: idx('volume') >= 0 ? parseFloat(cols[idx('volume')]) : 0,
});
}
return out;
}
export const __defaults = DEFAULTS;
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
classifyCandle,
detectSweep,
generateSignal,
backtest,
parseCsv,
} from './strategy.mjs';
test('classifyCandle: bullish hammer (long lower wick, tiny body in upper third)', () => {
// body = 0.0001, lower wick = 0.005, upper wick = 0.0001
const bar = { open: 1.1055, close: 1.1056, high: 1.1057, low: 1.1005, time: 1 };
assert.equal(classifyCandle(bar), 'hammer');
});
test('classifyCandle: shooting star (long upper wick, tiny body)', () => {
// body = 0.0001, upper wick = 0.005, lower wick = 0.0001
const bar = { open: 1.1056, close: 1.1055, high: 1.1106, low: 1.1054, time: 1 };
assert.equal(classifyCandle(bar), 'shooting_star');
});
test('classifyCandle: doji is not a hammer', () => {
const bar = { open: 1.105, close: 1.105, high: 1.106, low: 1.104, time: 1 };
assert.equal(classifyCandle(bar), 'doji');
});
test('classifyCandle: a normal trend bar is rejected', () => {
const bar = { open: 1.100, close: 1.106, high: 1.1062, low: 1.0998, time: 1 };
assert.equal(classifyCandle(bar), 'other');
});
test('detectSweep: identifies a low sweep that closes back inside', () => {
// 10 bars all sitting around 1.1050 ± 5 pips, then a bar that wicks
// to 1.1010 and recovers to close at 1.1055.
const bars = [];
for (let i = 0; i < 10; i++) {
bars.push({ open: 1.1050, close: 1.1052, high: 1.1056, low: 1.1045, time: i });
}
bars.push({ open: 1.1052, close: 1.1055, high: 1.1056, low: 1.1010, time: 10 });
const sweep = detectSweep(bars, 10);
assert.ok(sweep);
assert.equal(sweep.type, 'low');
assert.equal(sweep.level, 1.1045);
});
test('detectSweep: identifies a high sweep that fails back inside', () => {
const bars = [];
for (let i = 0; i < 10; i++) {
bars.push({ open: 1.1050, close: 1.1048, high: 1.1056, low: 1.1045, time: i });
}
bars.push({ open: 1.1048, close: 1.1045, high: 1.1100, low: 1.1044, time: 10 });
const sweep = detectSweep(bars, 10);
assert.ok(sweep);
assert.equal(sweep.type, 'high');
assert.equal(sweep.level, 1.1056);
});
test('detectSweep: returns null when wick stays inside prior range', () => {
const bars = [];
for (let i = 0; i < 10; i++) {
bars.push({ open: 1.1050, close: 1.1052, high: 1.1060, low: 1.1040, time: i });
}
bars.push({ open: 1.1051, close: 1.1054, high: 1.1058, low: 1.1042, time: 10 });
assert.equal(detectSweep(bars, 10), null);
});
test('generateSignal: long signal on hammer + low sweep at 2R', () => {
const bars = [];
for (let i = 0; i < 10; i++) {
bars.push({ open: 1.1050, close: 1.1052, high: 1.1056, low: 1.1045, time: i });
}
bars.push({ open: 1.1052, close: 1.1056, high: 1.1057, low: 1.1010, time: 10 });
const sig = generateSignal(bars, { lookback: 10, rr: 2 });
assert.ok(sig, 'expected a signal');
assert.equal(sig.direction, 'long');
assert.equal(sig.pattern, 'hammer-sweep-low');
// Stop = low - 1 pip = 1.1009; entry = close = 1.1056; stopDist = 0.0047
// Target = 1.1056 + 0.0047 * 2 = 1.1150
assert.ok(Math.abs(sig.stop - 1.1009) < 1e-9);
assert.ok(Math.abs(sig.target - 1.1150) < 1e-6);
});
test('generateSignal: short signal on shooting-star + high sweep at 1R', () => {
const bars = [];
for (let i = 0; i < 10; i++) {
bars.push({ open: 1.1050, close: 1.1048, high: 1.1056, low: 1.1045, time: i });
}
bars.push({ open: 1.1048, close: 1.1045, high: 1.1100, low: 1.1044, time: 10 });
const sig = generateSignal(bars, { lookback: 10, rr: 1 });
assert.ok(sig);
assert.equal(sig.direction, 'short');
assert.equal(sig.pattern, 'star-sweep-high');
assert.equal(sig.rr, 1);
});
test('generateSignal: rejects setup when stop distance < minStopPips guardrail', () => {
const bars = [];
for (let i = 0; i < 10; i++) {
bars.push({ open: 1.1050, close: 1.1052, high: 1.1056, low: 1.1045, time: i });
}
// Wicks just barely below 1.1045 (1 pip), close 1.1055 — distance ~10 pips,
// but with minStopPips = 50 we should reject it.
bars.push({ open: 1.1052, close: 1.1055, high: 1.1056, low: 1.1044, time: 10 });
const sig = generateSignal(bars, { lookback: 10, rr: 2, minStopPips: 50 });
assert.equal(sig, null);
});
test('generateSignal: returns null when no sweep occurred', () => {
const bars = [];
for (let i = 0; i < 12; i++) {
bars.push({ open: 1.105, close: 1.106, high: 1.1062, low: 1.1048, time: i });
}
assert.equal(generateSignal(bars, { lookback: 10 }), null);
});
test('backtest: returns at least one winning trade on synthetic hammer-sweep', () => {
const bars = [];
for (let i = 0; i < 20; i++) {
bars.push({ open: 1.1050, close: 1.1052, high: 1.1056, low: 1.1045, time: i });
}
// Sweep bar: wicks to 1.1010, closes 1.1056 (hammer)
bars.push({ open: 1.1052, close: 1.1056, high: 1.1057, low: 1.1010, time: 20 });
// Stop = 1.1009, entry = 1.1056, stopDist = 0.0047, target (rr=2) = 1.1150
// March price up so the high crosses 1.1150 within ~10 bars
for (let i = 21; i < 40; i++) {
const drift = (i - 20) * 0.0010;
bars.push({
open: 1.1056 + drift,
close: 1.1058 + drift,
high: 1.1062 + drift,
low: 1.1054 + drift,
time: i,
});
}
const r = backtest(bars, { lookback: 10, rr: 2 });
assert.ok(r.total >= 1, 'expected at least one trade');
assert.equal(r.trades[0].direction, 'long');
assert.equal(r.trades[0].result, 'win');
assert.ok(r.expectancyR > 0);
});
test('backtest: stop hit produces loss outcome', () => {
const bars = [];
for (let i = 0; i < 20; i++) {
bars.push({ open: 1.1050, close: 1.1052, high: 1.1056, low: 1.1045, time: i });
}
bars.push({ open: 1.1052, close: 1.1056, high: 1.1057, low: 1.1010, time: 20 });
// Stop = 1.1009 — drop the next bar below it.
bars.push({ open: 1.1056, close: 1.1000, high: 1.1058, low: 1.1000, time: 21 });
const r = backtest(bars, { lookback: 10, rr: 2 });
assert.equal(r.total, 1);
assert.equal(r.trades[0].result, 'loss');
});
test('parseCsv: round-trips a small OHLCV table', () => {
const csv =
'time,open,high,low,close,volume\n' +
'1,1.1050,1.1056,1.1045,1.1052,100\n' +
'2,1.1052,1.1057,1.1048,1.1055,120\n';
const rows = parseCsv(csv);
assert.equal(rows.length, 2);
assert.equal(rows[0].open, 1.1050);
assert.equal(rows[1].close, 1.1055);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment