输入/搜索内容
3
关注
227
关注者
支撑阻力不是一条线:用 Rust 在优宽量化上做一次可回测实验
交流分享
创建于 2026-07-21 17:22:51  更新于 2026-07-22 11:43:59
 0
 5

img

你大概见过这种行情。

辛辛苦苦画出一条支撑线,反复放大、缩小、核对前低,最后得出结论:这里稳得像承重墙。于是挂单、设止损,等市场按剧本办事。

价格来了,先在支撑附近装模作样地停了一下,接着一根针扎穿止损。你刚准备研究是不是有人专门盯着自己这一手螺纹钢,行情又拉了回去,顺着原来判断的方向跑远了。

这类经历很容易把人带进两个误区:要么认为支撑阻力全是玄学,要么把止损继续缩小,试图用更精准的点位打败市场。结果通常是线越画越细,账户也跟着越磨越薄。

问题可能不在支撑阻力本身,而在于我们把一个活生生的博弈过程,压成了一个没有厚度的数字。

一条画在图上的水平线不会主动买入,也不会替你拦住空头。真正可能起作用的,是一批交易者在相近价格附近同时改变决策:有人开始抄底,有人止盈平空,有人撤掉卖单,还有人只是觉得“这个位置大家应该都在看”。零散资金在同一个区域汇合,才可能形成阶段性的承接或压制。

换个说法:关键位置更像一声集结号,而不是一道激光防盗线。号吹了,人不一定来;人来了,也不一定守得住。

这正是量化研究可以介入的地方。

我们无法从 K 线里直接读取“市场共识”,但可以寻找一些代理变量:摆动高低点是否集中、价格进入区域后是否迅速收回、弹开距离有多大、同一区域被测试了几次、每次反应是在增强还是减弱。

本文不打算证明支撑阻力是一条永不失效的规律。目标更朴素:把一篇偏交易认知的文章,翻译成一组可以在优宽量化上运行、回测、对照和推翻的 Rust 规则。

img

策略原型不是答案,更像一台测谎仪。文章里的观点到底有没有用,交给历史数据说话。

一、先把思路翻译成量化变量

思路核心并不是“关键点位用止损赌一下”,而是把支撑阻力理解成多空力量可能集中交锋的区域。把这个思路落到代码,大致可以拆成下面几组假设:

交易观点策略中的近似实现需要注意的地方
支撑阻力不是精确点摆动点中心 ± ATR 宽度ATR 只是波动代理,不是天然正确的宽度
关键位置来自共同关注相邻摆动点合并为同一区域K 线只能间接描述共识
碰到位置不等于守住要求收盘重新离开区域,并出现影线和弹开距离长影线本身不是必涨、必跌信号
反复触碰可能消耗力量统计独立触碰次数,比较相邻反应强度“第三次最好”只是待检验经验,不是定律
支撑跌破后可能变阻力收盘有效突破后进行角色转换转换后仍需等待回抽确认
大势会影响局部反应用快慢 EMA 做背景过滤EMA 只是简化背景,不代表完整趋势结构

img

这里有一个边界必须先说清楚:代码里的参数不是从数学定理里推出来的。0.35 ATR、三次触碰、0.8 ATR 的反应距离,都是研究起点。它们可以被调整,更应该接受消融测试。

二、支撑不是一条粉笔线,而是一段有厚度的城墙

img

真实的争夺不会只发生在 3500.00 这个价格上。

有人挂在 3502,有人等 3498,有人看到快速下杀后才追着买,还有人会故意把止损放到整数位外面。把所有人硬塞到一个点上,就像把一场攻城战画成一条没有宽度的铅笔线:看着干净,和现场没什么关系。

所以,策略先用 ATR 给区域增加厚度:

rust
let width = pivot_atr * ZONE_WIDTH_ATR; let lower = pivot_price - width; let upper = pivot_price + width;

参数暂时设为:

rust
const ZONE_WIDTH_ATR: f64 = 0.35;

假设某个摆动低点是 3500,当时 ATR 为 30,区域就是:

text
3489.5 ~ 3510.5

这不是说价格落进这个区间就一定反弹,只是告诉策略:战斗可能会在这里发生,先别急着冲进去当第一个烈士。

附近如果又出现一个摆动低点,也不必马上再画一条线。两个中心距离足够近,就把它们合并:

rust
if (zone.center - center).abs() <= atr * MERGE_DISTANCE_ATR { zone.center = (zone.center + center) / 2.0; }

这种合并是在模拟一个朴素事实:交易者很少对 35003503 形成两套截然不同的集体记忆,它们更可能属于同一片价格地带。

三、摆动点要等右侧行情走出来,别拿后视镜开车

img

支撑阻力在历史图上总是特别清楚。

行情走完以后,最低点像是拿荧光笔标过,谁看都知道那里重要。可在最低点刚出现的那一刻,它也可能只是下跌途中的一个小台阶。回测如果当场就把它认定成支撑,相当于偷偷看了后面的答案。

本文用左右各三根 K 线确认摆动点:

rust
let pivot_index = closed_index - PIVOT_SPAN; if is_pivot_low(&bars, pivot_index, PIVOT_SPAN) { update_zone(&mut support, bars[pivot_index].low, pivot_atr); }

只有右侧三根 K 线已经结束,候选低点才获得“摆动低点”的身份。

这会让区域晚出现三根 K 线,但这个延迟是必要成本。量化策略宁可迟到,也不要穿越。

四、关键区域像集结号,但吹号不等于一定有人来

一个很有意思的观点:关键位置之所以有作用,不是因为线条有魔法,而是它可能把原本分散的资金组织到同一个地方。

把市场想成一片混乱战场,平时的每次跳动都像远处的脚步声、吵闹声和马车声。大部分声音听完就过去了。只有到了大家都认识的关口,散兵游勇才可能停下来,看看周围是不是有人准备一起还手。

但量化程序看不到谁在吹号,也看不到谁在集合。我们只能用价格行为做间接判断。

首先,让摆动点重合形成区域:

rust
fn update_zone(slot: &mut Option<Zone>, center: f64, atr: f64) { if let Some(zone) = slot.as_mut() { if (zone.center - center).abs() <= atr * MERGE_DISTANCE_ATR { zone.center = (zone.center + center) / 2.0; return; } } *slot = Some(Zone::new(center, atr, false)); }

其次,价格真正来到区域以后,不立即开仓,而是继续观察有没有人“响应号角”。如果价格进了支撑区以后趴在那里不动,甚至收在区域下方,那就说明号可能吹了,但队伍还在食堂。

五、碰到区域只是敲门,重新弹开才算屋里有人

img

传统策略最爱写的一句是:

text
价格 <= 支撑价 → 做多

这句话足够简单,也足够容易让手续费开心。

本文要求支撑区出现一次可测量的拒绝反应。多头信号会计算下影线比例和从最低价到收盘价的弹开距离:

rust
let lower_wick = bar.open.min(bar.close) - bar.low; let wick_ratio = lower_wick / (bar.high - bar.low); let reaction = (bar.close - bar.low) / atr;

还要满足收盘重新回到区域上方:

rust
let reclaimed = bar.close > zone.upper && bar.close > bar.open; let reaction_ok = reaction >= MIN_REACTION_ATR; let wick_ok = wick_ratio >= MIN_WICK_RATIO;

阻力区采用相反规则。

这里没有把长下影线写成“主力吸筹”之类的万能解释。影线只说明这根 K 线里低位出现过反向推动,至于它是不是有效,需要看收盘有没有收回、离开距离够不够,以及后续能不能延续。

长下影线更像现场有人还了一拳,不代表这场仗已经赢了。

六、同一堵墙撞得越多,不一定越结实

触碰次数有两面性。

第一次反弹,市场可能只觉得是巧合;第二次、第三次又守住,关注的人自然会增加。可另一面是,每次价格下来,都会消耗一部分等待买入的订单。墙的名气越来越大,砖也可能越来越少。

所以“测试次数越多,支撑越强”并不完整。更值得观察的是:每次测试以后,价格还能弹多远。

策略先给独立触碰设置冷却,避免把一段贴着区域横盘的行情统计成十次英勇守城:

rust
if approached_from_outside && bar_seq - zone.last_touch_bar >= TOUCH_COOLDOWN_BARS { zone.touches += 1; zone.last_touch_bar = bar_seq; }

当前原型只交易前三次独立测试:

rust
if zone.touches == 0 || zone.touches > MAX_TOUCHES { return None; }

然后比较本次反应与上一次反应:

rust
let decaying = zone.last_reaction > 0.0 && reaction < zone.last_reaction * DECAY_RATIO;

如果上次弹开 1.2 ATR,这次只剩 0.6 ATR,区域虽然还没正式失守,防守动作已经明显变弱。此时继续把它当铜墙铁壁,更像是在给即将倒下的墙写表扬信。

这仍然只是一个待检验假设。更严谨的做法,是在回测后把第一次、第二次、第三次以及更多次触碰分组统计,而不是先认定“三次最好”。

七、支撑和阻力会换阵营,城头上的旗也会换

支撑被有效跌破以后,原来在这里买入的人可能被套。价格以后反弹回来,一部分人会选择回本离场;与此同时,新空头也可能把这里当成防守位置。

于是,过去聚集买盘的地方,后来可能聚集卖盘。

这就像一座城门失守以后,建筑还在,城头的旗已经换了。昨天它替多头挡箭,今天它可能替空头收过路费。

代码要求收盘价真正离开区域,并额外越过一段 ATR 缓冲,才确认角色转换:

rust
if closed.close < support.lower - BREAK_BUFFER_ATR * atr { resistance = Some(Zone::new(support.center, atr, true)); support = None; }

阻力向上突破后转成支撑,逻辑相同。

需要强调的是,角色转换不等于立刻反手。区域换旗之后,策略仍要等待价格回抽并出现新的拒绝。否则一次突破就马上追单,很容易从“守城失败”切换成“追着败军跑”,最后两边都挨打。

八、局部守得再漂亮,也别逆着整支大军冲锋

单边下跌里也会出现长下影线,也会出现短暂反弹。只看局部形态,很容易把空头趋势中的喘口气误认为大反转。

本文使用 20 EMA 和 60 EMA 作为简化背景:

rust
let long_trend = ema_fast[index] > ema_slow[index] && ema_fast[index] > ema_fast[index - 3]; let short_trend = ema_fast[index] < ema_slow[index] && ema_fast[index] < ema_fast[index - 3];

上涨背景只交易支撑拒绝,下跌背景只交易阻力拒绝。

EMA 当然看不到所有市场结构,它只是给策略加一道很朴素的限制:大军正在往南走时,不要因为路边三个人挥了挥旗,就组织一支临时小队向北冲锋。

九、止损要放在战场外,不要贴着城门缝

既然支撑阻力被定义为区域,止损继续放在区域内部就有些自相矛盾。

区域内部本来就是最容易反复争夺的地方。把止损贴在边缘,相当于特意站在城门口,要求所有流箭精准绕开自己。

多单止损放在支撑区下沿之外,再留一段 ATR 缓冲:

rust
let stop = zone.lower - STOP_BUFFER_ATR * atr; let risk = entry - stop; let target = entry + risk * RISK_REWARD;

完整策略暂时使用:

rust
const STOP_BUFFER_ATR: f64 = 0.25; const RISK_REWARD: f64 = 2.0;

止损距离小于 0.25 ATR 或大于 2 ATR 都不交易。前者太容易被普通噪音击中,后者说明入场已经离区域过远,风险收益开始变形。

策略另外设置 24 根 K 线的时间退出。价格既然声称在关键区域完成了拒绝,就应该在合理时间内有所表现。迟迟不走的信号,未必是“正在蓄力”,也可能只是当初判断错了。

img

十、优宽量化 Rust 完整策略

下面是一份用于商品期货回测的研究原型:

  • 合约使用 rb888
  • 信号周期为 15 分钟;
  • 每次固定交易 1 手;
  • 只保留最近一个支撑区和最近一个阻力区;
  • 使用已经结束的倒数第二根 K 线生成信号;
  • 根据最新 K 线时间去重,避免同一根 K 线重复执行;
  • 通过 LogStatus! 输出状态表格;
  • 同一根 K 线同时触及止损和止盈时,按止损优先处理。

最后一条很重要。只用 OHLC 数据时,我们并不知道一根 K 线内部究竟先碰止损还是先碰止盈。选择止损优先不一定还原真实路径,但至少不会让回测在模糊处占便宜。

rust
// ============================================================ // 支撑阻力关键区域策略 · 优宽量化 Rust // // 修复重点: // 1. 回测环境使用 IsVirtual(),不再卡在真实CTP连接等待。 // 2. 回测轮询改为60秒,避免15分钟策略每500毫秒空转。 // 3. 每个等待分支都有状态输出,不再“看起来没反应”。 // 4. 保留最近的有效支撑/阻力,而不是被每个远端摆动点覆盖。 // 5. 将影线与反应衰减改为评分项,减少条件叠加造成的零信号。 // 6. 所有合约、行情、映射和下单失败都会输出明确原因。 // 7. 增加KLineChart绘图:支撑/阻力区域、EMA、触碰、有效拒绝、 // 角色转换、实际开平仓,以及持仓成本/止损/目标线。 // 8. 所有支持 symbol 的接口均显式传入合约代码,不依赖全局合约状态。 // 9. 实盘仅调用 SetContractType("rb888") 获取 InstrumentID 映射, // 下单改用 CreateOrder(真实合约, side, price, amount)。 // // 研究用途:商品期货回测/实盘原型,信号合约 rb888、15分钟、固定1手。 // ============================================================ const CONTRACT: &str = "rb888"; // 信号合约/主力连续合约 const MAPPING_REFRESH_BARS: i64 = 16; // 实盘每16根15分钟Bar刷新一次主力映射 const ATR_PERIOD: usize = 14; const FAST_EMA: usize = 20; const SLOW_EMA: usize = 60; const PIVOT_SPAN: usize = 3; const MIN_BARS: usize = 80; const ZONE_WIDTH_ATR: f64 = 0.45; const MERGE_DISTANCE_ATR: f64 = 0.70; const BREAK_BUFFER_ATR: f64 = 0.25; const STOP_BUFFER_ATR: f64 = 0.30; const RECLAIM_TOLERANCE_ATR: f64 = 0.10; const MIN_REACTION_ATR: f64 = 0.55; const WICK_SCORE_RATIO: f64 = 0.20; const DECAY_RATIO: f64 = 0.55; const MIN_SCORE: i32 = 5; const MAX_TOUCHES: u32 = 4; const TOUCH_COOLDOWN_BARS: i64 = 3; const SIGNAL_COOLDOWN_BARS: i64 = 8; const RISK_REWARD: f64 = 2.0; const MIN_RISK_ATR: f64 = 0.20; const MAX_RISK_ATR: f64 = 2.50; const MAX_HOLD_BARS: u32 = 32; const ORDER_SIZE: i64 = 1; #[derive(Clone, Copy, PartialEq)] enum ZoneKind { Support, Resistance, } #[derive(Clone, Copy)] struct Bar { time: i64, open: f64, high: f64, low: f64, close: f64, } #[derive(Clone, Copy)] struct Zone { center: f64, lower: f64, upper: f64, touches: u32, last_touch_bar: i64, last_signal_bar: i64, last_reaction: f64, flipped: bool, } impl Zone { fn new(center: f64, atr: f64, flipped: bool) -> Self { let width = atr * ZONE_WIDTH_ATR; Self { center, lower: center - width, upper: center + width, touches: 0, last_touch_bar: -1_000_000, last_signal_bar: -1_000_000, last_reaction: 0.0, flipped, } } } #[derive(Clone, Copy)] struct Signal { side: i32, score: i32, reaction: f64, wick_ratio: f64, stop_anchor: f64, touches: u32, flipped: bool, } struct PositionState { side: i32, entry: f64, stop: f64, target: f64, bars_held: u32, // 保存实际开仓合约。主力映射切换后,旧仓仍必须在原合约平仓。 symbol: String, } impl PositionState { fn flat() -> Self { Self { side: 0, entry: 0.0, stop: 0.0, target: 0.0, bars_held: 0, symbol: String::new(), } } } // 保存本根K线需要绘制的真实开平仓标记。 // direction 可选:long / short / closelong / closeshort。 struct ChartSignal { direction: &'static str, price: f64, text: String, } fn is_pivot_low(bars: &[Bar], index: usize, span: usize) -> bool { if index < span || index + span >= bars.len() { return false; } let value = bars[index].low; for i in (index - span)..=(index + span) { if i != index && bars[i].low <= value { return false; } } true } fn is_pivot_high(bars: &[Bar], index: usize, span: usize) -> bool { if index < span || index + span >= bars.len() { return false; } let value = bars[index].high; for i in (index - span)..=(index + span) { if i != index && bars[i].high >= value { return false; } } true } // 只保留距离当前价格最近、仍有意义的支撑或阻力。 fn update_zone( slot: &mut Option<Zone>, kind: ZoneKind, center: f64, atr: f64, current_close: f64, ) { let valid_side = match kind { ZoneKind::Support => center < current_close, ZoneKind::Resistance => center > current_close, }; if !valid_side { return; } if let Some(zone) = slot.as_mut() { if (zone.center - center).abs() <= atr * MERGE_DISTANCE_ATR { zone.center = (zone.center + center) / 2.0; let width = atr * ZONE_WIDTH_ATR; zone.lower = zone.center - width; zone.upper = zone.center + width; return; } let is_nearer = match kind { ZoneKind::Support => center > zone.center, ZoneKind::Resistance => center < zone.center, }; if !is_nearer { return; } } *slot = Some(Zone::new(center, atr, false)); } fn evaluate_zone( zone: &mut Zone, kind: ZoneKind, bars: &[Bar], index: usize, atr: f64, ema_fast: &[f64], ema_slow: &[f64], bar_seq: i64, ) -> (Option<Signal>, String) { if index < 3 { return (None, "历史数据不足".to_string()); } let bar = bars[index]; let previous = bars[index - 1]; let intersects = bar.low <= zone.upper && bar.high >= zone.lower; if !intersects { return (None, "未触及区域".to_string()); } let approached_from_outside = match kind { ZoneKind::Support => previous.close > zone.upper, ZoneKind::Resistance => previous.close < zone.lower, }; if approached_from_outside && bar_seq - zone.last_touch_bar >= TOUCH_COOLDOWN_BARS { zone.touches += 1; zone.last_touch_bar = bar_seq; } if zone.touches == 0 { return (None, "区域内震荡,未形成独立触碰".to_string()); } if zone.touches > MAX_TOUCHES { return (None, format!("触碰次数过多: {}", zone.touches)); } if bar_seq - zone.last_signal_bar < SIGNAL_COOLDOWN_BARS { return (None, "信号冷却中".to_string()); } let range = (bar.high - bar.low).max(0.0000001); let ( side, wick_ratio, reaction, reclaimed, trend_ok, slope_ok, stop_anchor, ) = match kind { ZoneKind::Support => { let wick = bar.open.min(bar.close) - bar.low; ( 1, (wick / range).max(0.0), (bar.close - bar.low) / atr, bar.close >= zone.upper - RECLAIM_TOLERANCE_ATR * atr && bar.close > bar.open, ema_fast[index] > ema_slow[index], ema_fast[index] > ema_fast[index - 3], zone.lower, ) } ZoneKind::Resistance => { let wick = bar.high - bar.open.max(bar.close); ( -1, (wick / range).max(0.0), (bar.high - bar.close) / atr, bar.close <= zone.lower + RECLAIM_TOLERANCE_ATR * atr && bar.close < bar.open, ema_fast[index] < ema_slow[index], ema_fast[index] < ema_fast[index - 3], zone.upper, ) } }; if !reclaimed { return ( None, format!("触及但未收回,反应{:.2}ATR", reaction), ); } let previous_reaction = zone.last_reaction; let weakening = previous_reaction > 0.0 && reaction < previous_reaction * DECAY_RATIO; zone.last_reaction = reaction; let mut score = 0; score += 2; // 已收回区域 if reaction >= MIN_REACTION_ATR { score += 2; } if wick_ratio >= WICK_SCORE_RATIO { score += 1; } if trend_ok { score += 2; } if slope_ok { score += 1; } if zone.touches <= 3 { score += 1; } if zone.flipped { score += 1; } if weakening { score -= 2; } if reaction < MIN_REACTION_ATR { return ( None, format!("反应不足: {:.2}ATR", reaction), ); } if !trend_ok { return (None, "趋势背景不一致".to_string()); } if score < MIN_SCORE { return ( None, format!("评分不足: {}", score), ); } ( Some(Signal { side, score, reaction, wick_ratio, stop_anchor, touches: zone.touches, flipped: zone.flipped, }), format!("信号通过,评分{}", score), ) } fn level_text(zone: Option<Zone>) -> String { zone .map(|z| format!("{:.2}[{}次]", z.center, z.touches)) .unwrap_or_else(|| "-".to_string()) } fn json_text(value: &str) -> String { value .replace('\\', "\\\\") .replace('"', "\\\"") .replace('\n', " ") } // 兼容不同托管者/API桥接版本对 IO("status") 的字符串表示。 // 实盘中常见成功值为 "1",官方 Rust 示例中也出现过 "true"。 fn ctp_connected(status: &str) -> bool { matches!(status.trim().to_ascii_lowercase().as_str(), "1" | "true") } fn main() { LogReset(1); let is_backtest = IsVirtual(); let poll_ms = if is_backtest { 60_000 } else { 1_000 }; // 自定义图表叠加在主K线图上。 // 支撑、阻力、均线、持仓风控线和交易标记都通过该对象绘制。 let mut chart = KLineChart::new( r#"{"overlay": true, "pricePrecision": 2, "volumePrecision": 0}"#, ); let mut support: Option<Zone> = None; let mut resistance: Option<Zone> = None; let mut position = PositionState::flat(); // 回测中,rb888可以直接交易;实盘中,rb888只用于信号行情, // trade_symbol 保存 SetContractType(rb888) 返回的真实主力合约。 let mut trade_symbol = if is_backtest { CONTRACT.to_string() } else { String::new() }; let mut last_mapping_refresh_bar: i64 = -1_000_000; let mut last_current_bar_time: i64 = 0; let mut last_pivot_time: i64 = 0; let mut bar_seq: i64 = 0; let mut support_pivots: u32 = 0; let mut resistance_pivots: u32 = 0; let mut signal_count: u32 = 0; let mut last_event = "策略启动".to_string(); let mut last_eval = "等待第一根新Bar".to_string(); Log!( "关键区域策略启动,环境:", if is_backtest { "回测" } else { "实盘" }, "合约:", CONTRACT ); loop { // 回测不等待真实CTP连接;实盘必须先确认CTP登录成功。 // 不在状态栏拼接 _D(None):实盘容器可能使用 UTC, // 而网页日志时间通常按本地时区显示,两者并排容易造成“差8小时”的错觉。 // 页面本身已经带有日志时间,因此这里只显示连接状态。 if !is_backtest { let ctp_status = exchange.IO("status").unwrap_or_default(); if !ctp_connected(&ctp_status) { // 断线后清空映射,恢复连接时重新解析主力合约。 trade_symbol.clear(); last_event = format!( "实盘未连接CTP前置,当前状态={},10秒后重试", if ctp_status.is_empty() { "空" } else { &ctp_status } ); LogStatus!(last_event.clone()); Sleep(10_000); continue; } // SetContractType在这里仅用于解析rb888当前映射的真实合约。 // 后续行情接口均显式传symbol,下单使用CreateOrder,不依赖全局合约。 let mapping_due = trade_symbol.is_empty() || bar_seq - last_mapping_refresh_bar >= MAPPING_REFRESH_BARS; if mapping_due { match exchange.SetContractType(CONTRACT) { Ok(raw_contract_info) => { let mut mapped = String::new(); if let Some(info) = JSONParse(&raw_contract_info) { mapped = info["InstrumentID"] .as_str() .unwrap_or("") .trim() .to_string(); } if mapped.is_empty() { if trade_symbol.is_empty() { last_event = format!( "{}主力映射失败:返回值中没有InstrumentID", CONTRACT ); LogStatus!(last_event.clone()); Sleep(10_000); continue; } else { Log!( "主力映射刷新失败,继续使用旧合约:", trade_symbol.as_str() ); } } else { if mapped != trade_symbol { let old_symbol = if trade_symbol.is_empty() { "-".to_string() } else { trade_symbol.clone() }; Log!( "主力映射更新:", CONTRACT, old_symbol, "->", mapped.as_str(), if position.side != 0 { ";已有持仓仍按开仓合约管理" } else { "" } ); trade_symbol = mapped; } last_mapping_refresh_bar = bar_seq; last_event = format!( "主力映射就绪: {} -> {}", CONTRACT, trade_symbol ); } } Err(error) => { if trade_symbol.is_empty() { last_event = format!("主力映射请求失败: {:?}", error); LogStatus!(last_event.clone()); Sleep(10_000); continue; } else { Log!( "主力映射刷新请求失败,继续使用旧合约:", trade_symbol.as_str(), "错误:", error ); } } } } } // GetRecords支持symbol参数,直接请求rb888,不依赖SetContractType的全局状态。 let records = match exchange.GetRecords(Some(CONTRACT), PERIOD_M15, None) { Ok(value) => value, Err(error) => { last_event = format!("获取K线失败: {:?}", error); LogStatus!(last_event.clone()); Sleep(poll_ms); continue; } }; if records.len() < MIN_BARS { last_event = format!( "等待K线数据: {}/{}", records.len(), MIN_BARS ); LogStatus!(last_event.clone()); Sleep(poll_ms); continue; } let current_bar_time = records[records.len() - 1].Time; // 只在新的15分钟Bar出现时计算一次。 if current_bar_time == last_current_bar_time { Sleep(poll_ms); continue; } last_current_bar_time = current_bar_time; bar_seq += 1; let bars: Vec<Bar> = records .iter() .map(|r| Bar { time: r.Time, open: r.Open, high: r.High, low: r.Low, close: r.Close, }) .collect(); let atr_values = TA.ATR(&records, ATR_PERIOD); let ema_fast = TA.EMA(&records, FAST_EMA); let ema_slow = TA.EMA(&records, SLOW_EMA); let closed_index = bars.len() - 2; let closed = bars[closed_index]; let atr = atr_values[closed_index]; // 1:支撑跌破转阻力;2:阻力突破转支撑。 // 该标记会在当前K线上画旗帜。 let mut role_flip_kind: i32 = 0; if !atr.is_finite() || atr <= 0.0 || !ema_fast[closed_index].is_finite() || !ema_slow[closed_index].is_finite() { last_event = "指标尚未就绪".to_string(); LogStatus!(last_event.clone()); Sleep(poll_ms); continue; } // 确认新的摆动点。 let pivot_index = closed_index - PIVOT_SPAN; if bars[pivot_index].time != last_pivot_time { last_pivot_time = bars[pivot_index].time; let pivot_atr = atr_values[pivot_index]; if pivot_atr.is_finite() && pivot_atr > 0.0 { if is_pivot_low(&bars, pivot_index, PIVOT_SPAN) { update_zone( &mut support, ZoneKind::Support, bars[pivot_index].low, pivot_atr, closed.close, ); support_pivots += 1; last_event = format!( "确认摆动低点: {:.2}", bars[pivot_index].low ); } if is_pivot_high(&bars, pivot_index, PIVOT_SPAN) { update_zone( &mut resistance, ZoneKind::Resistance, bars[pivot_index].high, pivot_atr, closed.close, ); resistance_pivots += 1; last_event = format!( "确认摆动高点: {:.2}", bars[pivot_index].high ); } } } // 有效突破后进行角色转换。 if let Some(zone) = support { if closed.close < zone.lower - BREAK_BUFFER_ATR * atr { resistance = Some(Zone::new(zone.center, atr, true)); support = None; role_flip_kind = 1; last_event = format!( "支撑跌破并转为阻力: {:.2}", zone.center ); } } if let Some(zone) = resistance { if closed.close > zone.upper + BREAK_BUFFER_ATR * atr { support = Some(Zone::new(zone.center, atr, true)); resistance = None; role_flip_kind = 2; last_event = format!( "阻力突破并转为支撑: {:.2}", zone.center ); } } let (long_signal, long_eval) = match support.as_mut() { Some(zone) => evaluate_zone( zone, ZoneKind::Support, &bars, closed_index, atr, &ema_fast, &ema_slow, bar_seq, ), None => (None, "暂无支撑区".to_string()), }; let (short_signal, short_eval) = match resistance.as_mut() { Some(zone) => evaluate_zone( zone, ZoneKind::Resistance, &bars, closed_index, atr, &ema_fast, &ema_slow, bar_seq, ), None => (None, "暂无阻力区".to_string()), }; last_eval = format!( "多:{} | 空:{}", long_eval, short_eval ); // 图表诊断标记:是否触及区域、是否形成有效拒绝。 let support_touched = support .map(|z| closed.low <= z.upper && closed.high >= z.lower) .unwrap_or(false); let resistance_touched = resistance .map(|z| closed.low <= z.upper && closed.high >= z.lower) .unwrap_or(false); let long_reaction_ok = long_signal.is_some(); let short_reaction_ok = short_signal.is_some(); let signal = match (long_signal, short_signal) { (Some(a), Some(b)) => { if a.score > b.score || (a.score == b.score && a.reaction >= b.reaction) { Some(a) } else { Some(b) } } (Some(a), None) => Some(a), (None, Some(b)) => Some(b), _ => None, }; // 空仓时读取当前映射合约;持仓时读取实际持仓合约。 // 这样即使主力映射发生切换,旧仓的退出参考价也不会读错合约。 let quote_symbol = if position.side != 0 && !position.symbol.is_empty() { position.symbol.clone() } else { trade_symbol.clone() }; let ticker = match exchange.GetTicker(Some(quote_symbol.as_str())) { Ok(value) => value, Err(error) => { last_event = format!( "获取Ticker失败[{}]: {:?}", quote_symbol, error ); LogStatus!(last_event.clone()); Sleep(poll_ms); continue; } }; let price = ticker.Last; if !price.is_finite() || price <= 0.0 { last_event = format!("Ticker价格无效: {}", price); LogStatus!(last_event.clone()); Sleep(poll_ms); continue; } let mut exited_this_bar = false; let mut chart_trade_signal: Option<ChartSignal> = None; // -------------------- // 持仓退出 // -------------------- if position.side != 0 { position.bars_held += 1; let stop_hit = if position.side == 1 { closed.low <= position.stop } else { closed.high >= position.stop }; let target_hit = if position.side == 1 { closed.high >= position.target } else { closed.low <= position.target }; let timed_out = position.bars_held >= MAX_HOLD_BARS; if stop_hit || target_hit || timed_out { let close_side = if position.side == 1 { "closebuy" } else { "closesell" }; // 使用持仓自身保存的真实合约平仓,避免主力切换后平错合约。 let close_result = exchange.CreateOrder( position.symbol.as_str(), close_side, -1.0, ORDER_SIZE, ); match close_result { Ok(order_id) => { let close_direction = if position.side == 1 { "closelong" } else { "closeshort" }; last_event = if stop_hit { "止损退出".to_string() } else if target_hit { "达到2R退出".to_string() } else { "超时退出".to_string() }; chart_trade_signal = Some(ChartSignal { direction: close_direction, price, text: last_event.clone(), }); Log!( last_event.clone(), "合约:", position.symbol.as_str(), "订单:", order_id, "参考价:", price ); position = PositionState::flat(); exited_this_bar = true; } Err(error) => { last_event = format!( "平仓下单失败[{} {}]: {:?}", position.symbol, close_side, error ); } } } } // -------------------- // 空仓开仓 // -------------------- if position.side == 0 && !exited_this_bar { if let Some(signal) = signal { signal_count += 1; let stop = if signal.side == 1 { signal.stop_anchor - STOP_BUFFER_ATR * atr } else { signal.stop_anchor + STOP_BUFFER_ATR * atr }; let risk = (price - stop).abs(); let risk_atr = risk / atr; let stop_valid = if signal.side == 1 { stop < price } else { stop > price }; if !stop_valid || risk_atr < MIN_RISK_ATR || risk_atr > MAX_RISK_ATR { last_event = format!( "信号被风险过滤: {:.2}ATR", risk_atr ); } else { let target = if signal.side == 1 { price + risk * RISK_REWARD } else { price - risk * RISK_REWARD }; let open_side = if signal.side == 1 { "buy" } else { "sell" }; // CreateOrder直接指定真实合约和方向,无需SetDirection或全局SetContractType。 let open_result = exchange.CreateOrder( trade_symbol.as_str(), open_side, -1.0, ORDER_SIZE, ); match open_result { Ok(order_id) => { position = PositionState { side: signal.side, entry: price, stop, target, bars_held: 0, symbol: trade_symbol.clone(), }; if signal.side == 1 { if let Some(zone) = support.as_mut() { zone.last_signal_bar = bar_seq; } } else if let Some(zone) = resistance.as_mut() { zone.last_signal_bar = bar_seq; } last_event = format!( "{} {} 评分{} 反应{:.2}ATR 触碰{}次{}", if signal.side == 1 { "开多" } else { "开空" }, trade_symbol, signal.score, signal.reaction, signal.touches, if signal.flipped { " 角色转换区" } else { "" } ); chart_trade_signal = Some(ChartSignal { direction: if signal.side == 1 { "long" } else { "short" }, price, text: format!( "{} {} S{} R{:.2}", if signal.side == 1 { "开多" } else { "开空" }, trade_symbol, signal.score, signal.reaction, ), }); Log!( last_event.clone(), "订单:", order_id, "影线比例:", signal.wick_ratio, "止损:", stop, "目标:", target ); } Err(error) => { last_event = format!( "开仓下单失败[{} {}]: {:?}", trade_symbol, open_side, error ); } } } } } // -------------------- // 自定义图表 // 注意:包含十六进制颜色(例如 #ff9800)的 JSON 必须使用 // r##"..."##,不能使用 r#"..."#,否则 `"#` 会提前结束原始字符串。 // -------------------- // KLineChart要求每一根K线的绘制都必须位于begin/close之间。 chart.begin(&records[closed_index]); // 信号候选背景:绿色表示有效多头拒绝,红色表示有效空头拒绝, // 黄色表示触及区域但没有形成最终候选信号。 let background = if long_reaction_ok { "rgba(38, 166, 154, 0.10)" } else if short_reaction_ok { "rgba(239, 83, 80, 0.10)" } else if support_touched || resistance_touched { "rgba(255, 193, 7, 0.08)" } else { "rgba(0, 0, 0, 0)" }; chart.bgcolor(background, "{}"); // 趋势背景。 chart.plot( ema_fast[closed_index], r##"{"title":"EMA20","color":"#ff9800","linewidth":1,"style":"line"}"##, ); chart.plot( ema_slow[closed_index], r##"{"title":"EMA60","color":"#3f51b5","linewidth":1,"style":"line"}"##, ); // 支撑区域:上下沿两条线之间用半透明绿色填充。 let support_upper = support.map(|z| z.upper).unwrap_or(f64::NAN); let support_lower = support.map(|z| z.lower).unwrap_or(f64::NAN); let support_upper_line = chart.plot( support_upper, r#"{"title":"支撑上沿","color":"rgba(0,150,90,0.95)","linewidth":1,"style":"linebr"}"#, ); let support_lower_line = chart.plot( support_lower, r#"{"title":"支撑下沿","color":"rgba(0,150,90,0.95)","linewidth":1,"style":"linebr"}"#, ); chart.fill( support_upper_line, support_lower_line, r#"{"title":"支撑区","color":"rgba(0,180,110,0.13)","fillgaps":false}"#, ); // 阻力区域:上下沿两条线之间用半透明红色填充。 let resistance_upper = resistance.map(|z| z.upper).unwrap_or(f64::NAN); let resistance_lower = resistance.map(|z| z.lower).unwrap_or(f64::NAN); let resistance_upper_line = chart.plot( resistance_upper, r#"{"title":"阻力上沿","color":"rgba(220,60,60,0.95)","linewidth":1,"style":"linebr"}"#, ); let resistance_lower_line = chart.plot( resistance_lower, r#"{"title":"阻力下沿","color":"rgba(220,60,60,0.95)","linewidth":1,"style":"linebr"}"#, ); chart.fill( resistance_upper_line, resistance_lower_line, r#"{"title":"阻力区","color":"rgba(239,83,80,0.13)","fillgaps":false}"#, ); // 当前持仓的成本、止损和目标。空仓时传入NaN形成断线。 chart.plot( if position.side != 0 { position.entry } else { f64::NAN }, r##"{"title":"持仓成本","color":"#212121","linewidth":2,"style":"linebr"}"##, ); chart.plot( if position.side != 0 { position.stop } else { f64::NAN }, r##"{"title":"止损","color":"#e53935","linewidth":2,"style":"linebr"}"##, ); chart.plot( if position.side != 0 { position.target } else { f64::NAN }, r##"{"title":"止盈目标","color":"#43a047","linewidth":2,"style":"linebr"}"##, ); // 触碰与有效拒绝标记。圆点只表示价格进入了区域; // 三角形表示该根K线已经通过反应距离与趋势过滤。 chart.plotshape( support_touched, r##"{"title":"触碰支撑","style":"circle","location":"belowbar","color":"#26a69a","text":"触支","textcolor":"#00695c","size":"10px"}"##, ); chart.plotshape( resistance_touched, r##"{"title":"触碰阻力","style":"circle","location":"abovebar","color":"#ef5350","text":"触阻","textcolor":"#b71c1c","size":"10px"}"##, ); chart.plotshape( long_reaction_ok, r##"{"title":"有效多头拒绝","style":"triangle_up","location":"belowbar","color":"#00897b","text":"有效反应","textcolor":"#00695c","size":"14px"}"##, ); chart.plotshape( short_reaction_ok, r##"{"title":"有效空头拒绝","style":"triangle_down","location":"abovebar","color":"#e53935","text":"有效反应","textcolor":"#b71c1c","size":"14px"}"##, ); // 角色转换旗帜。 chart.plotshape( role_flip_kind == 1, r##"{"title":"支撑转阻力","style":"flag","location":"abovebar","color":"#d32f2f","text":"支转阻","textcolor":"#ffffff","size":"14px"}"##, ); chart.plotshape( role_flip_kind == 2, r##"{"title":"阻力转支撑","style":"flag","location":"belowbar","color":"#00796b","text":"阻转支","textcolor":"#ffffff","size":"14px"}"##, ); // 只有订单请求成功后才绘制真实开平仓信号。 if let Some(marker) = chart_trade_signal.as_ref() { chart.signal( marker.direction, marker.price, ORDER_SIZE as f64, marker.text.as_str(), ); } chart.close(); // -------------------- // 状态栏 // -------------------- let position_text = match position.side { 1 => format!("多 {} @ {:.2}", position.symbol, position.entry), -1 => format!("空 {} @ {:.2}", position.symbol, position.entry), _ => "空仓".to_string(), }; let risk_text = if position.side == 0 { "-".to_string() } else { format!( "止损 {:.2} / 目标 {:.2} / 已持有{}Bar", position.stop, position.target, position.bars_held ) }; let table = format!( r#"{{"type":"table","title":"关键区域策略 · Symbol直传版","cols":["项目","值"],"rows":[["环境","{}"],["信号合约","{}"],["交易合约","{}"],["15分钟Bar序号","{}"],["K线数量","{}"],["已结束Bar时间","{}"],["收盘","{:.2}"],["ATR","{:.2}"],["支撑","{}"],["阻力","{}"],["摆动低/高","{}/{}"],["候选信号数","{}"],["持仓","{}"],["风控","{}"],["条件诊断","{}"],["最近事件","{}"]]}}"#, if is_backtest { "回测" } else { "实盘" }, CONTRACT, trade_symbol, bar_seq, records.len(), closed.time, closed.close, atr, level_text(support), level_text(resistance), support_pivots, resistance_pivots, signal_count, position_text, risk_text, json_text(&last_eval), json_text(&last_event) ); LogStatus!(format!("`{}`", table)); Sleep(poll_ms); } }

十一、第一次回测,先确认策略有没有老实干活

第一轮不要急着盯收益曲线,也不要一看到亏损就开始转参数旋钮。先检查策略行为是否符合设计。

重点看这几件事:

  1. 同一根 K 线有没有重复开仓;
  2. 同一区域是否在短时间内反复进出;
  3. 开仓信号是否真的发生在区域触碰并收回以后;
  4. 止损是否大量出现在开仓后的一两根 K 线;
  5. 第一次、第二次、第三次触碰的结果是否存在差异;
  6. 角色转换交易是否比普通区域交易更有优势。

如果策略一天交易几十次,先别夸它勤奋。大概率是区域统计、冷却或新 K 线去重出了问题。

如果大量交易刚开仓就止损,也不要第一反应把止损放宽。先检查拒绝确认是不是太松、区域是不是太窄、趋势过滤是不是形同虚设。止损像安全带,不是用来遮住发动机故障灯的。

十二、真正有意义的是对照实验

这类策略最容易陷入“解释一切”的陷阱。盈利时说区域有效,亏损时说是假突破;反弹了说支撑强,没反弹说主力洗盘。只要词汇够多,任何图都能讲通。

量化研究的价值,就是不给故事无限续杯。

可以依次做以下对照:

text
A:ZONE_WIDTH_ATR = 0 把区域退化成精确点位,检验“区域”是否真的更好。 B:取消影线和弹开距离 价格触碰后直接入场,检验“等待反应”是否有价值。 C:取消 MAX_TOUCHES 允许区域被无限次交易,观察后续触碰是否明显变差。 D:取消反应衰减过滤 不比较前后弹开距离,检验衰减判断是否提供增量。 E:取消 EMA 背景 同时交易顺势与逆势信号,比较趋势过滤的作用。 F:取消角色转换 只交易原始摆动区域,单独评估“城头换旗”是否有效。

比较时至少记录:

  • 交易次数;
  • 胜率;
  • 单笔平均盈亏;
  • 盈亏比;
  • 最大回撤;
  • 最长连续亏损;
  • 手续费和滑点提高后的变化;
  • 不同触碰次数下的分组表现。

如果一条规则删掉以后表现反而更好,就别舍不得。策略不是纪念馆,没必要把每个漂亮观点都供在里面。

十三、这个原型没有解决什么

为了让第一版容易理解和验证,代码刻意没有实现所有维度。

目前没有包括:

  • 多周期关键区域叠加;
  • 成交密集区与成交量确认;
  • 持仓量变化;
  • 缺口分类;
  • 整数关口;
  • 日内时间节点;
  • 真实盘口中的挂单、撤单和成交队列。

这不是说这些内容没有价值,而是一次把所有东西塞进去,回测即使盈利,也很难知道究竟是谁立了功。

代码还有几个实际限制:

  • 固定交易 1 手,没有根据账户权益和合约乘数计算仓位;
  • 订单请求成功后就更新内部持仓,没有进一步核对部分成交;
  • 平仓触发来自已结束 K 线,不是真正挂在交易所的止损单;
  • 策略重启后不会自动从账户持仓恢复内部状态;
  • 实盘还需要处理今仓、昨仓、换月和委托滑价。

因此,这份代码适合回测研究,不适合不加修改就接上真实资金。

结语:关键区域不是下注理由,而是观察地点

支撑阻力最容易给人的错觉,是让一条画出来的线看上去像一份承诺。

但市场从来没签过这份合同。

关键区域能做的,只是把注意力集中到一个更可能发生博弈的地方。价格到了以后,是否真的出现承接,反应是否足够强,连续测试后力量有没有衰减,突破以后角色是否改变,这些都需要重新观察。

所以,这个策略并不是“到关键位置带止损赌一下”。它做的是另一件事:

text
先找到可能吹响集结号的地方, 再看看有没有人真的集合, 最后用止损承认自己也可能看错。

把交易观点写成代码,不会让市场变得确定,但会让我们的判断少一点自我感动,多一点可检验性。

策略地址:https://www.youquant.com/strategy/414320

相关推荐
评论
全部评论 (0)
暂无数据
暂无数据
  • 1
社区
回测系统
APP 下载
iPhone 下载
© 2015 - ∞ YouQuant 豫ICP备19046564号