← Todos los ejemplos
Ejemplo 79
Trend flip
the volume-confirmed trend flip, on 4-hour bars, exactly as the research rule (docs/research/PREREGISTRATION-TREND-FLIP.md, Amendments 1-4), written as a strategy with every number a parameter the optimizer
- Categoría
- Host: necesita los datos y el motor de la terminal
- Permisos
- orders.submit
- Antes de ejecutarlo
- BTCUSDT aggregated prints and one-minute bars; at least `trend_bars` + `lookback` 4-hour bars of history before the first trade
- Qué hace
- asks the terminal to run the strategy; once running it places orders on the account the person chose.
- Cómo ejecutarlo
- Insert strategy in the Backtests window (backtest and optimize); Add script on a replay (the same handlers run there).
- Lenguaje
>=0.2.0, <0.3.0- Host
>=0.1.26, <0.2.0- Necesita
- features >=0.1.9, <0.2.0handlers >=0.1.2, <0.2.0trade >=0.1.0, <0.2.0
Código
// Purpose: the volume-confirmed trend flip, on 4-hour bars, exactly as the research rule (docs/research/PREREGISTRATION-TREND-FLIP.md, Amendments 1-4), written as a strategy with every number a parameter the optimizer // may move (ADR-0015). // // THE IDEA (the user's): a long, developed trend ends, price makes a high-volume move against it (the first leg of a flip), and price bounces back toward where the pullback traded most. Trade the flip from that // bounce: a resting limit at the POC of the pullback, a stop just beyond the trend's extreme, and targets at the volume the trend left behind (its farthest POC, with partial targets at every high-volume node on // the way). An uptrend that flips is sold; a downtrend that flips is bought (`dir` picks one side or both). // // TREND the top is the highest high of the last `trend_bars` 4-hour bars; the trend began at the lowest low of the last `lookback` bars, at least `trend_bars` before the top; it gained `min_gain` or more. // PULLBACK within `within` bars of the top, price falls by max(`reversal`, `frac` of the trend's gain) (in log terms), on bars whose mean volume is `vol_mult` times the trend's. // ENTRY a limit at the POC of the pullback's own volume profile (`entry_mode` 0), or at `retrace` of the way back to the top (`entry_mode` 1); not placed if price is already past it; moved as the // pullback makes new extremes; withdrawn `expiry` bars after the pullback was confirmed; only placed if the reward to the final target is at least `min_rr` times the risk. // STOP `stop_pct` beyond the trend's extreme, as a stop on the entry. // TARGETS the farthest POC of the trend's fixed-range volume profile (from the trend's lowest low to its top: the whole range and its four equal quarters; `target_mode` 1 uses the whole range only), preceded by // up to `partials` high-volume nodes at least half the risk from the entry; the position is shared equally among them, each a resting limit. // EXIT the targets, the stop, or `max_hold` bars at the market. // // HOW IT IS THE RESEARCH RULE: every volume profile is made of **minutes**, as in the research (each minute's volume-weighted average price, weighted by its volume, in bins of 10 bps; nodes in bins of 25 bps summed over // three). The handler runs on every closed minute, takes the minute's own VWAP from the difference of the session's cumulative volume and VWAP, keeps a histogram of each hour, and builds the 4-hour bars from the // minutes. The fixed-range profile is the sum of the hours of the trend (its four quarters are four equal runs of hours, exactly the quarters of minutes of the research), and the pullback's profile is built minute by // minute with the research's buffer (minutes after the pullback's lowest low join it only if a lower low is made). What still differs is the venue, not the rule: fills here are the engine's (a limit fills when the market // trades through it), where the research simulator filled on a minute's high or low; and the dataset is whichever one the backtest is given. // // HONEST NOTE: on 2020-2026 BTC this rule, as specified, did not hold up (docs/research/SUMMARY.md): the long side carried, the short side lost, and the trades are few (a handful a year). Optimizing its numbers on so few // trades fits noise; use a long span, read the verdict the Optimize tab gives, and test the result on days it never saw. A backtest of this strategy takes about three times as long as a simple one (a handler on every minute). // Category: host // Permissions: orders.submit // Preconditions: BTCUSDT aggregated prints and one-minute bars; at least `trend_bars` + `lookback` 4-hour bars of history before the first trade // Side effects: asks the terminal to run the strategy; once running it places orders on the account the person chose. // Run: Insert strategy in the Backtests window (backtest and optimize); Add script on a replay (the same handlers run there). lang ">=0.2.0, <0.3.0"; host "ttt" ">=0.1.26, <0.2.0"; requires "features" ">=0.1.9, <0.2.0"; requires "handlers" ">=0.1.2, <0.2.0"; requires "trade" ">=0.1.0, <0.2.0"; import std.math; import std.text; import std.list; import std.time; const BIN: Float = 0.001; // a bin of the POC profile: 10 bps of price const NODE_BIN: Float = 0.0025; // a bin of the high-volume-node profile: 25 bps const NODE_REACH: Int = 4; // a node is the largest smoothed bin within this many bins on each side const NODE_SHARE: Float = 0.5; // ... and holds at least this share of the largest const MAX_PARTIALS: Int = 5; const MINUTE_MS: Int = 60000; const BAR_MINUTES: Int = 240; const HOUR_MINUTES: Int = 60; const ENTRY_TAG: Int = 1; const EXIT_TAG: Int = 10; // exits are tagged EXIT_TAG, EXIT_TAG + 1, ... const KEEP_EXTRA: Int = 120; // bars kept beyond `lookback`, for a peak, its pullback and the wait // ── what the strategy remembers ── struct Bar { hi: Float, lo: Float, lhi: Float, // ln(hi) llo: Float, // ln(lo) vol: Float, } // The volume of one hour of minutes: a histogram in bins of 10 bps and one in bins of 25 bps (each minute at its volume-weighted average price). struct Hour { h10: Map<Int, Float>, h25: Map<Int, Float>, } // A minute of a pullback waiting in the buffer: it joins the profile only if a lower low is made. struct Pt { bin: Int, w: Float, } // The profile of a pullback, minute by minute, from the minute after the top. struct Leg { core: Map<Int, Float>, buffer: List<Pt>, extreme: Float, // the lowest low (an uptrend flipping) or the highest high (a downtrend flipping) so far has: Bool, changed: Bool, // the core grew since the entry was last placed } // A trend that has just made its top, waiting for the pullback that arms it. struct Peak { on: Bool, at: Int, start: Int, hx: Float, trend_volume: Float, need: Float, lx_min: Float, leg_volume: Float, leg_bars: Int, } struct Memory { // the minutes started: Bool, // a 4-hour boundary has been seen: bars are counted from it prev_pv: Float, // the session's cumulative volume x VWAP at the last minute prev_cv: Float, cur10: Map<Int, Float>, // the hour in progress cur25: Map<Int, Float>, bar_hi: Float, // the 4-hour bar in progress bar_lo: Float, bar_vol: Float, bar_minutes: Int, // the history bars: List<Bar>, first: Int, // the absolute number of bars[0] n: Int, // bars closed since the start hours: List<Hour>, hour_first: Int, // the absolute number of hours[0]; bar j covers hours 4j .. 4j + 3 // the setup up: Peak, // an uptrend that may flip down (sold) down: Peak, // a downtrend that may flip up (bought) up_leg: Leg, down_leg: Leg, skip: Int, // bar closes to ignore (the one a trade ended in) phase: Int, // 0 looking, 1 an entry is waiting, 2 in a trade sgn: Int, // of the trend being traded against: 1 an uptrend (we sell), -1 a downtrend (we buy) top: Float, // the trend's extreme price peak_at: Int, start_at: Int, armed_ms: Int, // when the pullback was confirmed level: Float, // where the entry rests (0 when none rests) stop: Float, pocs: List<Float>, // the POCs of the trend's profile the final target is chosen from nodes: List<Float>,// its high-volume nodes targets: List<Float>, entered_ms: Int, } struct Step { state: Memory, actions: List<handlers.Act>, } // The numbers the optimizer moves, gathered. struct Cfg { trend_bars: Int, lookback: Int, min_gain: Float, reversal: Float, frac: Float, vol_mult: Float, within: Int, entry_mode: Int, retrace: Float, stop_pct: Float, partials: Int, target_mode: Int, min_rr: Float, expiry: Int, max_hold: Int, dir: Int, size: Float, } struct Plan { ok: Bool, level: Float, stop: Float, targets: List<Float>, } // ── small helpers ── fn to_int(f: Float) -> Int { return text.parse_int(to_string(decimal_from_float(f, 0))).unwrap_or(0); } fn bin_of(price: Float, width: Float) -> Int { return to_int(math.ln(price) / width); } fn price_of(bin: Int, width: Float) -> Float { return math.exp(to_float(bin) * width); } fn no_peak() -> Peak { return Peak { on: false, at: 0, start: 0, hx: 0.0, trend_volume: 0.0, need: 0.0, lx_min: 0.0, leg_volume: 0.0, leg_bars: 0 }; } fn no_leg() -> Leg { return Leg { core: Map.new(), buffer: [], extreme: 0.0, has: false, changed: false }; } fn begin() -> Memory { return Memory { started: false, prev_pv: 0.0, prev_cv: 0.0, cur10: Map.new(), cur25: Map.new(), bar_hi: 0.0, bar_lo: 0.0, bar_vol: 0.0, bar_minutes: 0, bars: [], first: 0, n: 0, hours: [], hour_first: 0, up: no_peak(), down: no_peak(), up_leg: no_leg(), down_leg: no_leg(), skip: 0, phase: 0, sgn: 0, top: 0.0, peak_at: 0, start_at: 0, armed_ms: 0, level: 0.0, stop: 0.0, pocs: [], nodes: [], targets: [], entered_ms: 0, }; } fn bar_at(m: Memory, j: Int) -> Bar { return m.bars[j - m.first]; } // The extreme a trend of this sign makes (a high for an uptrend), and the opposite one, in log terms with the sign folded in. fn hx(b: Bar, sgn: Int) -> Float { if sgn > 0 { return b.lhi; } return 0.0 - b.llo; } fn lx(b: Bar, sgn: Int) -> Float { if sgn > 0 { return b.llo; } return 0.0 - b.lhi; } fn add_to(h: Map<Int, Float>, bin: Int, w: Float) -> Map<Int, Float> { let mut out = h; let now = out.get(bin).unwrap_or(0.0); out.insert(bin, now + w); return out; } // The sum of the hours `from ..= to` (absolute numbers) at one of the two resolutions. fn merge_hours(m: Memory, from: Int, to: Int, fine: Bool) -> Map<Int, Float> { let mut out: Map<Int, Float> = Map.new(); let mut h = math.max(from, m.hour_first); while h <= to { let hour = m.hours[h - m.hour_first]; for (k, v) in (if fine { hour.h10 } else { hour.h25 }) { let now = out.get(k).unwrap_or(0.0); out.insert(k, now + v); } h = h + 1; } return out; } // The price of the bin with the most volume (of equals, the lowest). fn poc_of(p: Map<Int, Float>, width: Float) -> Option<Float> { let mut best: Float = 0.0; let mut at = 0; let mut found = false; for (k, v) in p { if v > best { best = v; at = k; found = true; } } if found { return Some(price_of(at, width)); } return None; } // The high-volume nodes: prices where the profile, summed over three bins, peaks and holds at least half of its largest. fn nodes_of(p: Map<Int, Float>, width: Float) -> List<Float> { let mut out: List<Float> = []; let mut lo = 0; let mut hi = 0; let mut seen = false; for (k, v) in p { if !seen { lo = k; hi = k; seen = true; } else { if k < lo { lo = k; } if k > hi { hi = k; } } } if !seen { return out; } let mut smooth: List<Float> = []; let mut k = lo; while k <= hi { smooth.push(p.get(k - 1).unwrap_or(0.0) + p.get(k).unwrap_or(0.0) + p.get(k + 1).unwrap_or(0.0)); k = k + 1; } let mut top: Float = 0.0; for v in smooth { if v > top { top = v; } } let n = smooth.len(); let mut i = 0; while i < n { let v = smooth[i]; if v > 0.0 && v >= NODE_SHARE * top { let mut is_peak = true; let mut j = math.max(i - NODE_REACH, 0); let end = math.min(i + NODE_REACH + 1, n); while j < end { let w = smooth[j]; if !(w < v || (w == v && j >= i)) { is_peak = false; } j = j + 1; } if is_peak { out.push(price_of(lo + i, width)); } } i = i + 1; } return out; } // ── the trend's fixed-range profile: made once, when the pullback is confirmed ── // The POCs the final target is chosen from: the whole range, and (unless `target_mode` 1) its four equal quarters. The range runs from the first minute of the trend's lowest-low bar to the last of its top bar. fn trend_pocs(m: Memory, cfg: Cfg, start: Int, peak: Int) -> List<Float> { let mut pocs: List<Float> = []; let first_hour = start * 4; let last_hour = peak * 4 + 3; match poc_of(merge_hours(m, first_hour, last_hour, true), BIN) { Some(x) => { pocs.push(x); }, None => {}, } if cfg.target_mode == 0 { // The range is a whole number of bars, so four equal quarters of its minutes are four equal runs of hours. let q = (last_hour - first_hour + 1) / 4; let mut i = 0; while i < 4 { let a = first_hour + q * i; let z = a + q - 1; match poc_of(merge_hours(m, a, z, true), BIN) { Some(x) => { pocs.push(x); }, None => {}, } i = i + 1; } } return pocs; } // The targets of a trade in direction `d` (+1 buy, -1 sell) taken at `entry` with its stop at `stop`: the nodes between, then the farthest POC. Empty if there is none. fn targets_for(m: Memory, cfg: Cfg, d: Float, entry: Float, stop: Float) -> List<Float> { let risk = math.abs(entry - stop); let mut far: Float = 0.0; let mut best: Float = 0.0; for x in m.pocs { let gain = d * (x - entry); if gain > best { best = gain; far = x; } } let mut out: List<Float> = []; if best <= 0.0 { return out; } if cfg.partials > 0 { let mut between: List<Float> = []; for x in m.nodes { if d * (x - entry) >= 0.5 * risk && d * (far - x) > 0.0 { between.push(x); } } let ascending = list.sort(between); let nearest_first = if d > 0.0 { ascending } else { list.reverse(ascending) }; out = list.take(nearest_first, math.min(cfg.partials, MAX_PARTIALS)); } out.push(far); return out; } // ── the pullback's profile, a minute at a time ── fn leg_of(m: Memory, sgn: Int) -> Leg { if sgn > 0 { return m.up_leg; } return m.down_leg; } fn with_leg(m: Memory, sgn: Int, leg: Leg) -> Memory { let mut s = m; if sgn > 0 { s.up_leg = leg; } else { s.down_leg = leg; } return s; } // One more minute of a pullback: a new extreme adds the minutes held back and itself to the profile; any other minute waits in the buffer. fn leg_minute(leg: Leg, sgn: Int, lo: Float, hi: Float, bin: Int, v: Float) -> Leg { let mut l = leg; let fresh = if sgn > 0 { lo < l.extreme } else { hi > l.extreme }; if !l.has || fresh { l.extreme = if sgn > 0 { lo } else { hi }; l.has = true; for pt in l.buffer { l.core = add_to(l.core, pt.bin, pt.w); } l.buffer = []; if v > 0.0 { l.core = add_to(l.core, bin, v); } l.changed = true; } else { if v > 0.0 { l.buffer.push(Pt { bin: bin, w: v }); } } return l; } // ── the plan of one entry ── // Where the entry should rest, the stop, and the targets, given the pullback so far. `first` is the moment the pullback is confirmed: then an order is not placed if price is already past its level. fn plan_entry(m: Memory, cfg: Cfg, close: Float, first: Bool) -> Plan { let none = Plan { ok: false, level: 0.0, stop: 0.0, targets: [] }; let s = to_float(m.sgn); let d = 0.0 - s; let leg = leg_of(m, m.sgn); let mut level = leg.extreme + cfg.retrace * (m.top - leg.extreme); if cfg.entry_mode == 0 { match poc_of(leg.core, BIN) { Some(x) => { level = x; }, None => {}, } } if first && cfg.entry_mode == 0 && s * (close - level) >= 0.0 { return none; } let stop = m.top * (1.0 + s * cfg.stop_pct); let targets = targets_for(m, cfg, d, level, stop); if targets.len() == 0 { return none; } let reward = d * (targets[targets.len() - 1] - level); let risk = math.abs(level - stop); if risk <= 0.0 || reward < cfg.min_rr * risk { return none; } return Plan { ok: true, level: level, stop: stop, targets: targets }; } // ── orders ── fn submit(side: handlers.Side, intent: handlers.Intent, size: Float, limit: Option<Float>, stop_loss: Option<Float>, tag: Int, expire: Int) -> handlers.Act { return handlers.Act.Submit(handlers.Order { book: "main", side: side, intent: intent, qty: qty(decimal_from_float(size, 6)), limit: limit, stop: None, take_profit: None, stop_loss: stop_loss, tag: tag, post_only: false, immediate: false, expire_minutes: expire, }); } fn side_of(sgn: Int) -> handlers.Side { // We trade against the trend: sell the flip of an uptrend, buy the flip of a downtrend. if sgn > 0 { return handlers.Side.Sell; } return handlers.Side.Buy; } fn opposite(sgn: Int) -> handlers.Side { if sgn > 0 { return handlers.Side.Buy; } return handlers.Side.Sell; } fn entry_order(m: Memory, cfg: Cfg, level: Float, stop: Float, now_ms: Int) -> handlers.Act { let left = math.max(cfg.expiry * BAR_MINUTES - (now_ms - m.armed_ms) / MINUTE_MS, 1); return submit(side_of(m.sgn), handlers.Intent.Open, cfg.size, Some(level), Some(stop), ENTRY_TAG, left); } // The targets of an open position: every one but the last a share taken off at its price, the last closing what is left. fn exit_orders(m: Memory, size: Float) -> List<handlers.Act> { let mut acts: List<handlers.Act> = []; let count = m.targets.len(); if count == 0 { return acts; } let share = size / to_float(count); let mut i = 0; while i < count { let price = Some(m.targets[i]); if i == count - 1 { acts.push(submit(opposite(m.sgn), handlers.Intent.Close, share, price, None, EXIT_TAG + i, 0)); } else { acts.push(submit(opposite(m.sgn), handlers.Intent.Reduce, share, price, None, EXIT_TAG + i, 0)); } i = i + 1; } return acts; } fn cancel_exits(m: Memory) -> List<handlers.Act> { let mut acts: List<handlers.Act> = []; let mut i = 0; while i < m.targets.len() { acts.push(handlers.Act.Cancel(EXIT_TAG + i)); i = i + 1; } return acts; } // Back to looking: no trend being followed, nothing resting, nothing held. The bar in progress is not looked at (the research resumes at the first bar that starts after the trade or the order). fn reset(m: Memory) -> Memory { let mut s = m; s.up = no_peak(); s.down = no_peak(); s.up_leg = no_leg(); s.down_leg = no_leg(); s.phase = 0; s.sgn = 0; s.level = 0.0; s.targets = []; s.pocs = []; s.nodes = []; s.skip = 1; return s; } // Back to looking after a trade or an order ended: the bar in progress is not looked at, unless the end came with its close (then the next one is). fn resume(m: Memory, bar_closed: Bool) -> Memory { let mut s = reset(m); if bar_closed { s.skip = 0; } return s; } // ── finding a trend that flips ── fn peak_of(m: Memory, sgn: Int) -> Peak { if sgn > 0 { return m.up; } return m.down; } fn with_peak(m: Memory, sgn: Int, p: Peak) -> Memory { let mut s = m; if sgn > 0 { s.up = p; } else { s.down = p; } return s; } // One side at bar `b`: the trend's top, then the pullback that arms it. A new top starts the pullback's profile over; a peak that goes away drops it. fn scan_side(m: Memory, cfg: Cfg, b: Int, sgn: Int) -> Memory { let bar = bar_at(m, b); let before = peak_of(m, sgn); let mut peak = before; if peak.on && hx(bar, sgn) >= peak.hx { peak.on = false; } if !peak.on { let prior_from = b + 1 - cfg.trend_bars; if prior_from < m.first { return with_leg(with_peak(m, sgn, peak), sgn, no_leg()); } let mut prior_max: Float = 0.0 - 1000000.0; let mut j = prior_from; while j < b { let x = hx(bar_at(m, j), sgn); if x > prior_max { prior_max = x; } j = j + 1; } if hx(bar, sgn) >= prior_max { let from = math.max(b + 1 - cfg.lookback, m.first); let mut min_at = b; let mut min_val: Float = 1000000.0; let mut i = from; while i <= b { let y = lx(bar_at(m, i), sgn); if y < min_val { min_val = y; min_at = i; } i = i + 1; } if min_at + cfg.trend_bars <= b && hx(bar, sgn) - min_val >= math.ln(1.0 + cfg.min_gain) { let mut total: Float = 0.0; let mut k = min_at; while k <= b { total = total + bar_at(m, k).vol; k = k + 1; } let need = math.max(math.ln(1.0 + cfg.reversal), cfg.frac * (hx(bar, sgn) - min_val)); peak = Peak { on: true, at: b, start: min_at, hx: hx(bar, sgn), trend_volume: total / to_float(b - min_at + 1), need: need, lx_min: 1000000.0, leg_volume: 0.0, leg_bars: 0 }; } } // A peak made now starts its pullback's profile from the next minute; no peak, no profile. return with_leg(with_peak(m, sgn, peak), sgn, no_leg()); } peak.lx_min = math.min(peak.lx_min, lx(bar, sgn)); peak.leg_volume = peak.leg_volume + bar.vol; peak.leg_bars = peak.leg_bars + 1; if b - peak.at > cfg.within { peak.on = false; return with_leg(with_peak(m, sgn, peak), sgn, no_leg()); } let big_enough = peak.hx - peak.lx_min >= peak.need; let loud = cfg.vol_mult <= 0.0 || peak.leg_volume / to_float(peak.leg_bars) >= cfg.vol_mult * peak.trend_volume; let mut s = with_peak(m, sgn, peak); if big_enough && loud { s.sgn = sgn; } return s; } // ── the handlers ── fn on_minute(m: Memory, ctx: handlers.Context, hi: Float, lo: Float, close: Float, vol: Float, cumvol: Float, cumvwap: Float, trend_bars: Float, lookback: Float, min_gain: Float, reversal: Float, frac: Float, vol_mult: Float, within: Float, entry_mode: Float, retrace: Float, stop_pct: Float, partials: Float, target_mode: Float, min_rr: Float, expiry: Float, max_hold: Float, dir: Float, size: Float) -> Step { let cfg = Cfg { trend_bars: to_int(trend_bars), lookback: to_int(lookback), min_gain: min_gain, reversal: reversal, frac: frac, vol_mult: vol_mult, within: to_int(within), entry_mode: to_int(entry_mode), retrace: retrace, stop_pct: stop_pct, partials: to_int(partials), target_mode: to_int(target_mode), min_rr: min_rr, expiry: to_int(expiry), max_hold: to_int(max_hold), dir: to_int(dir), size: size, }; let now_ms = time.unix_ms(ctx.now); // The event is the close of a minute: its number is the one before the instant, and the minute ended on a boundary of an hour or a bar when the instant is one. let end_min = now_ms / MINUTE_MS; let mut s = m; let mut acts: List<handlers.Act> = []; // This minute's own volume-weighted average price, from what the session had traded before it and after it. let pv = cumvol * cumvwap; let moved = cumvol - s.prev_cv; let mut mv = close; if s.prev_cv > 0.0 && moved > 0.0 && cumvol >= s.prev_cv { mv = (pv - s.prev_pv) / moved; } s.prev_cv = cumvol; s.prev_pv = pv; // Bars are counted from the first 4-hour boundary seen. if !s.started { if end_min % BAR_MINUTES == 0 { s.started = true; s.bar_minutes = 0; s.bar_hi = 0.0; s.bar_lo = 0.0; s.bar_vol = 0.0; s.cur10 = Map.new(); s.cur25 = Map.new(); s.hour_first = 0; } return Step { state: s, actions: acts }; } // The minute joins its hour, its bar and the pullback in progress. if vol > 0.0 && mv > 0.0 { s.cur10 = add_to(s.cur10, bin_of(mv, BIN), vol); s.cur25 = add_to(s.cur25, bin_of(mv, NODE_BIN), vol); } if s.bar_minutes == 0 { s.bar_hi = hi; s.bar_lo = lo; } else { s.bar_hi = math.max(s.bar_hi, hi); s.bar_lo = math.min(s.bar_lo, lo); } s.bar_vol = s.bar_vol + vol; s.bar_minutes = s.bar_minutes + 1; // (an empty minute still counts for the pullback's extreme, as in the research) let bin = if mv > 0.0 { bin_of(mv, BIN) } else { 0 }; if s.phase == 0 { if s.up.on { s.up_leg = leg_minute(s.up_leg, 1, lo, hi, bin, vol); } if s.down.on { s.down_leg = leg_minute(s.down_leg, -1, lo, hi, bin, vol); } } else { if s.phase == 1 { s = with_leg(s, s.sgn, leg_minute(leg_of(s, s.sgn), s.sgn, lo, hi, bin, vol)); } } if end_min % HOUR_MINUTES == 0 { s.hours.push(Hour { h10: s.cur10, h25: s.cur25 }); s.cur10 = Map.new(); s.cur25 = Map.new(); let keep_hours = (cfg.lookback + KEEP_EXTRA) * 4; if s.hours.len() > keep_hours { match list.slice(s.hours, s.hours.len() - keep_hours, s.hours.len()) { Some(tail) => { s.hours = tail; }, None => {}, } } } let held = ctx.positions.len() > 0; let bar_closed = end_min % BAR_MINUTES == 0; // The 4-hour bar closed: it joins the history, whatever the strategy is doing. if bar_closed { s.bars.push(Bar { hi: s.bar_hi, lo: s.bar_lo, lhi: math.ln(s.bar_hi), llo: math.ln(s.bar_lo), vol: s.bar_vol }); s.n = s.n + 1; s.bar_minutes = 0; let keep = cfg.lookback + KEEP_EXTRA; if s.bars.len() > keep { match list.slice(s.bars, s.bars.len() - keep, s.bars.len()) { Some(tail) => { s.bars = tail; s.first = s.n - keep; }, None => {}, } } // The hours of the bars still kept: bar j covers hours 4j .. 4j + 3, and the hour list holds the last of them. s.hour_first = s.n * 4 - s.hours.len(); } let b = s.n - 1; // In a trade: wait for its end; leave at the market after `max_hold` bars. (The research resumes at the first bar that starts after the trade ends.) if s.phase == 2 { if !held { acts = cancel_exits(s); return Step { state: resume(s, bar_closed), actions: acts }; } if now_ms - s.entered_ms >= cfg.max_hold * BAR_MINUTES * MINUTE_MS { acts = cancel_exits(s); acts.push(submit(opposite(s.sgn), handlers.Intent.Close, cfg.size, None, None, EXIT_TAG + 50, 0)); } return Step { state: s, actions: acts }; } // An entry is waiting: its level follows the pullback, and it lapses. if s.phase == 1 { if held { s.phase = 2; s.entered_ms = now_ms; return Step { state: s, actions: exit_orders(s, cfg.size) }; } if (now_ms - s.armed_ms) / MINUTE_MS >= cfg.expiry * BAR_MINUTES { acts.push(handlers.Act.Cancel(ENTRY_TAG)); return Step { state: resume(s, bar_closed), actions: acts }; } let leg = leg_of(s, s.sgn); if leg.changed { s = with_leg(s, s.sgn, Leg { core: leg.core, buffer: leg.buffer, extreme: leg.extreme, has: leg.has, changed: false }); let plan = plan_entry(s, cfg, close, false); if !plan.ok { if s.level > 0.0 { acts.push(handlers.Act.Cancel(ENTRY_TAG)); s.level = 0.0; } } else if s.level == 0.0 || math.abs(plan.level / s.level - 1.0) > 0.000001 { if s.level > 0.0 { acts.push(handlers.Act.Cancel(ENTRY_TAG)); } acts.push(entry_order(s, cfg, plan.level, plan.stop, now_ms)); s.level = plan.level; s.stop = plan.stop; s.targets = plan.targets; } } return Step { state: s, actions: acts }; } // Looking happens at a bar's close. if !bar_closed { return Step { state: s, actions: acts }; } if s.skip > 0 { s.skip = s.skip - 1; return Step { state: s, actions: acts }; } // Looking: a trend that has topped and then fallen hard enough, on enough volume. if b >= cfg.trend_bars { let mut armed = 0; if cfg.dir <= 0 { // A trend up that flips down is sold: allowed unless only longs are wanted (dir = 1). s = scan_side(s, cfg, b, 1); if s.sgn == 1 { armed = 1; } } if armed == 0 && cfg.dir >= 0 { s = scan_side(s, cfg, b, -1); if s.sgn == -1 { armed = -1; } } if armed != 0 { let peak = peak_of(s, armed); s.peak_at = peak.at; s.start_at = peak.start; s.top = math.exp(to_float(armed) * peak.hx); s.armed_ms = now_ms; // The trend's fixed-range profile, once: its POCs and nodes do not change while the trade is planned. s.pocs = trend_pocs(s, cfg, peak.start, peak.at); s.nodes = nodes_of(merge_hours(s, peak.start * 4, peak.at * 4 + 3, false), NODE_BIN); let plan = plan_entry(s, cfg, close, true); if plan.ok { s.phase = 1; s.level = plan.level; s.stop = plan.stop; s.targets = plan.targets; s = with_leg(s, armed, Leg { core: leg_of(s, armed).core, buffer: leg_of(s, armed).buffer, extreme: leg_of(s, armed).extreme, has: leg_of(s, armed).has, changed: false }); acts.push(entry_order(s, cfg, plan.level, plan.stop, now_ms)); return Step { state: s, actions: acts }; } // Not worth placing (price already past the level, or the reward is too small): the next bar is skipped and the search starts over. s = reset(s); s.skip = 1; return Step { state: s, actions: acts }; } } return Step { state: s, actions: acts }; } // An order was filled: the entry (put the exits on), or the end of the trade (take what is left of them off). fn on_fill(m: Memory, ctx: handlers.Context, size: Float) -> Step { let mut s = m; let held = ctx.positions.len() > 0; if s.phase == 1 && held { s.phase = 2; s.entered_ms = time.unix_ms(ctx.now); return Step { state: s, actions: exit_orders(s, size) }; } if s.phase == 2 && !held { let withdrawn = cancel_exits(s); return Step { state: reset(s), actions: withdrawn }; } return Step { state: s, actions: [] }; } strategy trend_flip( @param(label: "Trend length (4h bars)", min: 40, max: 150, step: 5, group: "Trend", hint: "The top is the highest high of this many bars; the trend began at least this many bars before it.") trend_bars: Int, @param(label: "How far back a trend may begin", min: 100, max: 400, step: 25, group: "Trend") lookback: Int, @param(label: "Least gain of the trend", min: 0.10, max: 0.60, step: 0.05, group: "Trend", hint: "0.20 is 20 %.") min_gain: Decimal, @param(label: "Pullback: floor", min: 0.03, max: 0.15, step: 0.01, group: "Pullback", hint: "The least fall from the top, 0.05 is 5 %.") reversal: Decimal, @param(label: "Pullback: share of the trend", min: 0.10, max: 0.60, step: 0.05, group: "Pullback", hint: "The fall must also be this share of the trend's gain.") frac: Decimal, @param(label: "Pullback volume vs the trend's", min: 0, max: 3, step: 0.25, group: "Pullback", hint: "0 turns the volume condition off.") vol_mult: Decimal, @param(label: "Bars to make the pullback", min: 10, max: 60, step: 5, group: "Pullback") within: Int, @param(label: "Entry: 0 pullback POC, 1 retracement", min: 0, max: 1, step: 1, group: "Entry") entry_mode: Int, @param(label: "Entry: retracement", min: 0.20, max: 0.80, step: 0.05, group: "Entry", hint: "Used when the entry is a retracement of the pullback.") retrace: Decimal, @param(label: "Stop beyond the extreme", min: 0.001, max: 0.010, step: 0.001, group: "Exit", hint: "0.003 is 0.3 %.") stop_pct: Decimal, @param(label: "Partial targets", min: 0, max: 5, step: 1, group: "Exit", hint: "Up to this many high-volume nodes before the final target.") partials: Int, @param(label: "Target: 0 farthest POC, 1 whole-range POC", min: 0, max: 1, step: 1, group: "Exit") target_mode: Int, @param(label: "Least reward / risk", min: 0.5, max: 2.5, step: 0.25, group: "Exit") min_rr: Decimal, @param(label: "Bars the entry may wait", min: 20, max: 120, step: 10, group: "Exit") expiry: Int, @param(label: "Longest hold (bars)", min: 60, max: 720, step: 60, group: "Exit") max_hold: Int, @param(label: "Side: -1 sell only, 0 both, 1 buy only", min: -1, max: 1, step: 1, group: "Side") dir: Int, size: Decimal, ) version "1.0.0" { let src = features.Source.AggTrades; // The closed minute, and what the session had traded by its close: the handler works out the minute's own VWAP from the cumulative pair. let hi = features.bar_high(); let lo = features.bar_low(); let close = features.bar_close(); let vol = features.bar_volume(); let cumvol = features.volume(src); let cumvwap = features.vwap(src); state = begin(); handler bar(1) = on_minute(ctx, hi, lo, close, vol, cumvol, cumvwap, trend_bars, lookback, min_gain, reversal, frac, vol_mult, within, entry_mode, retrace, stop_pct, partials, target_mode, min_rr, expiry, max_hold, dir, size); handler fill = on_fill(ctx, size); } fn main() -> Result<Unit, Error> { // The research rule: 75-bar trends of 20 %, a pullback of max(5 %, a quarter of the trend) on 1.5x volume, the entry at the pullback's POC, both sides. let run = trade.run(trend_flip(75, 200, 0.20, 0.05, 0.25, 1.5, 30, 0, 0.5, 0.003, 5, 0, 1.0, 60, 540, 0, 0.01))?; print(run.name + " on " + run.target + ": " + run.state); return Ok(()); }