你大概见过这种行情。
辛辛苦苦画出一条支撑线,反复放大、缩小、核对前低,最后得出结论:这里稳得像承重墙。于是挂单、设止损,等市场按剧本办事。
价格来了,先在支撑附近装模作样地停了一下,接着一根针扎穿止损。你刚准备研究是不是有人专门盯着自己这一手螺纹钢,行情又拉了回去,顺着原来判断的方向跑远了。
这类经历很容易把人带进两个误区:要么认为支撑阻力全是玄学,要么把止损继续缩小,试图用更精准的点位打败市场。结果通常是线越画越细,账户也跟着越磨越薄。
问题可能不在支撑阻力本身,而在于我们把一个活生生的博弈过程,压成了一个没有厚度的数字。
一条画在图上的水平线不会主动买入,也不会替你拦住空头。真正可能起作用的,是一批交易者在相近价格附近同时改变决策:有人开始抄底,有人止盈平空,有人撤掉卖单,还有人只是觉得“这个位置大家应该都在看”。零散资金在同一个区域汇合,才可能形成阶段性的承接或压制。
换个说法:关键位置更像一声集结号,而不是一道激光防盗线。号吹了,人不一定来;人来了,也不一定守得住。
这正是量化研究可以介入的地方。
我们无法从 K 线里直接读取“市场共识”,但可以寻找一些代理变量:摆动高低点是否集中、价格进入区域后是否迅速收回、弹开距离有多大、同一区域被测试了几次、每次反应是在增强还是减弱。
本文不打算证明支撑阻力是一条永不失效的规律。目标更朴素:把一篇偏交易认知的文章,翻译成一组可以在优宽量化上运行、回测、对照和推翻的 Rust 规则。
策略原型不是答案,更像一台测谎仪。文章里的观点到底有没有用,交给历史数据说话。
一、先把思路翻译成量化变量
思路核心并不是“关键点位用止损赌一下”,而是把支撑阻力理解成多空力量可能集中交锋的区域。把这个思路落到代码,大致可以拆成下面几组假设:
| 交易观点 | 策略中的近似实现 | 需要注意的地方 |
|---|---|---|
| 支撑阻力不是精确点 | 摆动点中心 ± ATR 宽度 | ATR 只是波动代理,不是天然正确的宽度 |
| 关键位置来自共同关注 | 相邻摆动点合并为同一区域 | K 线只能间接描述共识 |
| 碰到位置不等于守住 | 要求收盘重新离开区域,并出现影线和弹开距离 | 长影线本身不是必涨、必跌信号 |
| 反复触碰可能消耗力量 | 统计独立触碰次数,比较相邻反应强度 | “第三次最好”只是待检验经验,不是定律 |
| 支撑跌破后可能变阻力 | 收盘有效突破后进行角色转换 | 转换后仍需等待回抽确认 |
| 大势会影响局部反应 | 用快慢 EMA 做背景过滤 | EMA 只是简化背景,不代表完整趋势结构 |
这里有一个边界必须先说清楚:代码里的参数不是从数学定理里推出来的。0.35 ATR、三次触碰、0.8 ATR 的反应距离,都是研究起点。它们可以被调整,更应该接受消融测试。
二、支撑不是一条粉笔线,而是一段有厚度的城墙
真实的争夺不会只发生在 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;
}
这种合并是在模拟一个朴素事实:交易者很少对 3500 和 3503 形成两套截然不同的集体记忆,它们更可能属于同一片价格地带。
三、摆动点要等右侧行情走出来,别拿后视镜开车
支撑阻力在历史图上总是特别清楚。
行情走完以后,最低点像是拿荧光笔标过,谁看都知道那里重要。可在最低点刚出现的那一刻,它也可能只是下跌途中的一个小台阶。回测如果当场就把它认定成支撑,相当于偷偷看了后面的答案。
本文用左右各三根 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));
}
其次,价格真正来到区域以后,不立即开仓,而是继续观察有没有人“响应号角”。如果价格进了支撑区以后趴在那里不动,甚至收在区域下方,那就说明号可能吹了,但队伍还在食堂。
五、碰到区域只是敲门,重新弹开才算屋里有人
传统策略最爱写的一句是:
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 线的时间退出。价格既然声称在关键区域完成了拒绝,就应该在合理时间内有所表现。迟迟不走的信号,未必是“正在蓄力”,也可能只是当初判断错了。
十、优宽量化 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);
}
}
十一、第一次回测,先确认策略有没有老实干活
第一轮不要急着盯收益曲线,也不要一看到亏损就开始转参数旋钮。先检查策略行为是否符合设计。
重点看这几件事:
- 同一根 K 线有没有重复开仓;
- 同一区域是否在短时间内反复进出;
- 开仓信号是否真的发生在区域触碰并收回以后;
- 止损是否大量出现在开仓后的一两根 K 线;
- 第一次、第二次、第三次触碰的结果是否存在差异;
- 角色转换交易是否比普通区域交易更有优势。
如果策略一天交易几十次,先别夸它勤奋。大概率是区域统计、冷却或新 K 线去重出了问题。
如果大量交易刚开仓就止损,也不要第一反应把止损放宽。先检查拒绝确认是不是太松、区域是不是太窄、趋势过滤是不是形同虚设。止损像安全带,不是用来遮住发动机故障灯的。
十二、真正有意义的是对照实验
这类策略最容易陷入“解释一切”的陷阱。盈利时说区域有效,亏损时说是假突破;反弹了说支撑强,没反弹说主力洗盘。只要词汇够多,任何图都能讲通。
量化研究的价值,就是不给故事无限续杯。
可以依次做以下对照:
text
A:ZONE_WIDTH_ATR = 0
把区域退化成精确点位,检验“区域”是否真的更好。
B:取消影线和弹开距离
价格触碰后直接入场,检验“等待反应”是否有价值。
C:取消 MAX_TOUCHES
允许区域被无限次交易,观察后续触碰是否明显变差。
D:取消反应衰减过滤
不比较前后弹开距离,检验衰减判断是否提供增量。
E:取消 EMA 背景
同时交易顺势与逆势信号,比较趋势过滤的作用。
F:取消角色转换
只交易原始摆动区域,单独评估“城头换旗”是否有效。
比较时至少记录:
- 交易次数;
- 胜率;
- 单笔平均盈亏;
- 盈亏比;
- 最大回撤;
- 最长连续亏损;
- 手续费和滑点提高后的变化;
- 不同触碰次数下的分组表现。
如果一条规则删掉以后表现反而更好,就别舍不得。策略不是纪念馆,没必要把每个漂亮观点都供在里面。
十三、这个原型没有解决什么
为了让第一版容易理解和验证,代码刻意没有实现所有维度。
目前没有包括:
- 多周期关键区域叠加;
- 成交密集区与成交量确认;
- 持仓量变化;
- 缺口分类;
- 整数关口;
- 日内时间节点;
- 真实盘口中的挂单、撤单和成交队列。
这不是说这些内容没有价值,而是一次把所有东西塞进去,回测即使盈利,也很难知道究竟是谁立了功。
代码还有几个实际限制:
- 固定交易 1 手,没有根据账户权益和合约乘数计算仓位;
- 订单请求成功后就更新内部持仓,没有进一步核对部分成交;
- 平仓触发来自已结束 K 线,不是真正挂在交易所的止损单;
- 策略重启后不会自动从账户持仓恢复内部状态;
- 实盘还需要处理今仓、昨仓、换月和委托滑价。
因此,这份代码适合回测研究,不适合不加修改就接上真实资金。
结语:关键区域不是下注理由,而是观察地点
支撑阻力最容易给人的错觉,是让一条画出来的线看上去像一份承诺。
但市场从来没签过这份合同。
关键区域能做的,只是把注意力集中到一个更可能发生博弈的地方。价格到了以后,是否真的出现承接,反应是否足够强,连续测试后力量有没有衰减,突破以后角色是否改变,这些都需要重新观察。
所以,这个策略并不是“到关键位置带止损赌一下”。它做的是另一件事:
text
先找到可能吹响集结号的地方,
再看看有没有人真的集合,
最后用止损承认自己也可能看错。
把交易观点写成代码,不会让市场变得确定,但会让我们的判断少一点自我感动,多一点可检验性。
- 1







