Visibility graphs can make money in financial markets
Authors: Rafał Rak (2026) Source: arXiv:2605.01300 (preprint, công bố 02/05/2026) Tag:
moi:2026-05-16#visibility-graph#vgrsi#rsi#multi-asset#preprint
Ý tưởng cốt lõi
RSI cổ điển của Wilder rất phổ biến nhưng cũng rất bị "thuần hoá" — gần như mọi platform trading đều có sẵn, và alpha tự nhiên của nó đã bị arb hết. Rak đề xuất một biến thể mới — Visibility Graphs Relative Strength Index (VGRSI) — dựa trên backward visibility graph của giá. Visibility graph là cấu trúc đồ thị trong vật lý thống kê: từ một time series, mỗi điểm là node, và 2 node nối nhau nếu "nhìn thấy được" (không bị điểm trung gian nào chắn). Đối với chuỗi giá, "backward visibility" tại điểm t là tập hợp các điểm trong quá khứ mà từ t có thể nhìn ngược về mà không bị điểm trung gian cao hơn cắt ngang. Số lượng node mà t có thể "thấy" trong cửa sổ N gần nhất là một invariant hình học của price path — và nó nắm bắt thông tin về cấu trúc đỉnh/đáy local mà RSI không có.
VGRSI được rescale về thang 0-100 tương tự RSI cổ điển, tạo signal: long khi VGRSI vượt ngưỡng dưới (oversold), short khi vượt ngưỡng trên (overbought). Tác giả test trên 3 asset đại diện 3 class: DJI30 (US index futures), EUR/USD (FX major), XAU/USD (commodity) — giai đoạn 2024-2025, tổng 503 trading day. Cấu trúc validation: optimisation window 30 ngày, test window 7 ngày, rolling forward (walk-forward chuẩn).
Kết quả nổi bật: tổng profit ~$340,000 với fixed size $1,000/trade, tương đương $676/day. Drawdown 10-18% so với vốn $10,000, trading intensity 3.3-4.8 trade/ngày, Sharpe 2.55-3.6. Đặc biệt nhất là Sharpe cao + drawdown moderate — chứng tỏ signal có tỷ lệ hit decent + size không quá lớn. Tác giả cẩn trọng nhấn mạnh đây là "promising tool" — không phải Holy Grail — nhưng kết quả 3 asset class khác nhau cho thấy edge từ geometry of price path là structural chứ không asset-specific.
Cảnh báo: backtest chỉ 2 năm (503 ngày), có thể chưa qua đủ regime. Period 2024-2025 là regime tương đối trending với volatility moderate — nhiều RSI variant cũng work trong cùng regime. Cần validate trên 2008-2009, 2020 (COVID flash crash) trước khi production.
Ứng dụng giao dịch chính
Công thức VGRSI tóm tắt (theo paper):
- Cho cửa sổ trượt N (paper dùng N ≈ 14-30 bars).
- Với mỗi điểm t trong cửa sổ, đếm số điểm i < t mà giá P_i "visible" từ P_t — tức không tồn tại j ∈ (i, t) sao cho:Đây là điều kiện hình học "đường thẳng từ (i, P_i) tới (t, P_t) không bị chắn".
P_j > P_i + (P_t - P_i) × (j - i) / (t - i) - Gọi
V_t= số node visible từ t. Trong cửa sổ N gần nhất, tính:V_up_t = Σ V_s × 1{ΔP_s > 0} (s ∈ [t-N+1, t]) V_down_t = Σ V_s × 1{ΔP_s < 0} - VGRSI_t = 100 × V_up_t / (V_up_t + V_down_t).
Trading rule (paper auto-strategy):
- Long entry khi VGRSI vượt từ dưới qua mức
low_thr(vd 30). - Short entry khi VGRSI rớt từ trên qua mức
high_thr(vd 70). - Optimize
low_thr,high_thr, N trên 30 ngày past → áp dụng 7 ngày tiếp. - Exit: opposite signal hoặc time-stop.
Position size: paper dùng fixed $1,000 mỗi trade. Cải tiến đề xuất: vol-target size = target_vol_USD / (ATR_14 × point_value).
Áp dụng đa thị trường
VN30F (Hợp đồng tương lai chỉ số Việt Nam)
VN30F là chỉ số "noisy" với nhiều khoảng nghỉ trưa và các bar daily ATC, nhưng VGRSI bản chất là scale-invariant (chỉ dựa vào geometry, không dựa vào tick size). Cách port:
- Dùng candle daily VN30F1M (close-to-close). Period N ≈ 20 bar (~1 tháng giao dịch).
- Walk-forward: optimization 60 ngày → test 15 ngày (rộng hơn paper vì VN30F noise hơn).
- Threshold đề xuất:
low_thr∈ [25, 35],high_thr∈ [65, 75]. Optimize bằng grid search. - Position size: 1 contract VN30F1M = ~150 triệu VND notional, vol target ~5 triệu/ngày → 1-2 HĐ.
- Cảnh báo: VN30F daily có 250 ngày/năm, walk-forward 2 năm chỉ cho ~25 test window — số sample khá thấp để claim Sharpe > 2. Backtest 2018-2026 trước khi go live.
- Intraday: trên VN30F 30-min bars (8-10 bar/ngày), VGRSI có thể có edge tốt hơn vì market microstructure VN30F có "memory" mạnh ở khung intraday (HFT VN30F vẫn chưa dày).
US equity futures (ES, NQ, RTY, YM, MNQ)
Paper test trên DJI30 (proxy DJIA cash) đạt $146,000 profit / 503 ngày — đây là asset cùng family với YM/MNQ. Có thể trực tiếp port:
- Dùng dữ liệu liên tục (continuous front-month) cho ES/NQ/RTY/YM/MNQ.
- Period N ≈ 14-21 cho daily, 14-30 cho 30-min intraday.
- Cost giả định: ES 0.5 điểm round-trip ($25/HĐ), NQ 0.5 điểm ($10/HĐ), MNQ 2 điểm ($1).
- Cảnh báo cross-asset: chiến lược cho 1 asset Sharpe 3 thường suy giảm khi áp dụng đồng thời 4 asset cùng family vì correlation cao — portfolio Sharpe có thể chỉ ~2.
- So với null result của Mesfin (cùng repo, MNQ 5-min OHLCV): VGRSI khác ở chỗ nó là multi-bar geometric feature chứ không phải single-bar threshold → có cơ hội escape upper bound của Mesfin's framework.
Crypto spot (BTC, ETH, altcoins)
Crypto 24/7 phù hợp với VGRSI vì paper dùng EUR/USD (cũng 24/5 không có "session") và đạt $69,000 profit:
- BTC daily: N = 14-21, walk-forward 30→7. Threshold tương tự FX.
- ETH/altcoin: vol cao hơn → N nên nhỏ hơn (10-14) để bắt regime nhanh hơn.
- Funding spot: không có, nhưng nếu margin trade thì lending rate USDT ~5-8% APR cần trừ vào PnL.
- 4h candle có thể là sweet spot — 6 bar/ngày × 365 = ~2200 bar/năm, đủ sample cho walk-forward dài.
Crypto perpetual futures
Perp crypto đặc biệt phù hợp vì có thể short với cùng cost long:
- BTC-PERP daily với N=14, leverage 2-3× → vol-target $20-30/day per $1k notional.
- Funding cost ăn vào PnL: trung bình +0.01% per 8h × 3 cycle × holding 2 ngày = +0.06% với long (lợi) hoặc -0.06% với short (hại). Phải bake vào backtest.
- Liquidation risk: với leverage 3× và stop từ VGRSI cross-back, max drawdown per trade ~ ATR_14, thường < 33% margin → an toàn.
- Cảnh báo: BTC-PERP 2024-2025 đã chứng kiến nhiều squeeze/cascade event không xuất hiện trong DJI/EUR/XAU. Test riêng trên dữ liệu Bybit/Binance perp.
Cân nhắc cross-market chung
- VGRSI khai thác geometry path, là feature ít crowded hơn moving average/RSI cổ điển — có khả năng survive arb decay lâu hơn.
- Threshold optimal khác nhau giữa asset class: FX (low vol) cần biên hẹp hơn (35/65), crypto (high vol) biên rộng hơn (20/80).
- Backtest 503 ngày của paper là giới hạn: cần validate qua nhiều regime (bull, bear, ranging, crash) trước khi commit.
- Sharpe 2.55-3.6 trong paper là gross, đã trừ commission notional 0.05% nhưng chưa trừ slippage thị trường mỏng (XAU/EUR off-hours). Net Sharpe có thể giảm 20-30%.
- Combine với volume-filter: chỉ trade khi volume bar > median 20 bar → giảm false signal trong session nhạt.
- Đừng overfit threshold: paper optimize trên 30 ngày, nếu bạn optimize 6 tham số trên 60 ngày, đa số kết quả OOS sẽ collapse.
Minh họa Python
Code dưới cài đặt VGRSI và backtest tối giản trên một series giá daily. Có thể thay bằng OHLCV BTC/ETH/VN30F.
# Visibility Graphs Relative Strength Index (VGRSI)
# Theo Rak (2026), arXiv:2605.01300
# Yêu cầu: numpy, pandas
import numpy as np
import pandas as pd
def count_backward_visible(prices: np.ndarray, t: int, window: int) -> int:
"""
Đếm số điểm i ∈ [t-window+1, t-1] "visible" từ điểm t theo backward visibility.
Điều kiện visible: không tồn tại j ∈ (i, t) sao cho
P_j > P_i + (P_t - P_i) × (j - i) / (t - i)
"""
start = max(0, t - window + 1)
if t - start < 2:
return 0
P_t = prices[t]
n_vis = 0
for i in range(start, t):
P_i = prices[i]
if t - i < 2:
n_vis += 1
continue
# Check tất cả j giữa i và t
slope = (P_t - P_i) / (t - i)
blocked = False
for j in range(i + 1, t):
line_val = P_i + slope * (j - i)
if prices[j] > line_val:
blocked = True
break
if not blocked:
n_vis += 1
return n_vis
def compute_vgrsi(prices: pd.Series, N: int = 14, window: int = 30) -> pd.Series:
"""
VGRSI = 100 × V_up / (V_up + V_down), trong cửa sổ N gần nhất.
V_up = số node visible × 1{ΔP > 0}, V_down tương tự với ΔP < 0.
`window` là kích thước cửa sổ tính visibility cho mỗi node (gợi ý 2N hoặc 30).
"""
p = prices.values.astype(float)
n = len(p)
V = np.zeros(n)
for t in range(1, n):
V[t] = count_backward_visible(p, t, window)
dP = np.diff(p, prepend=p[0])
sign_up = (dP > 0).astype(float)
sign_down = (dP < 0).astype(float)
out = pd.Series(np.nan, index=prices.index, name="VGRSI")
for t in range(N, n):
Vu = (V[t - N + 1 : t + 1] * sign_up[t - N + 1 : t + 1]).sum()
Vd = (V[t - N + 1 : t + 1] * sign_down[t - N + 1 : t + 1]).sum()
if Vu + Vd > 0:
out.iloc[t] = 100.0 * Vu / (Vu + Vd)
return out
def vgrsi_strategy_backtest(
prices: pd.Series,
N: int = 14,
window: int = 30,
low_thr: float = 30.0,
high_thr: float = 70.0,
cost_bps: float = 5.0,
) -> pd.DataFrame:
"""
Long khi VGRSI cross-up từ dưới low_thr (oversold reverse).
Short khi VGRSI cross-down từ trên high_thr (overbought reverse).
Exit khi opposite signal.
"""
vg = compute_vgrsi(prices, N=N, window=window)
pos = pd.Series(0, index=prices.index, dtype=int)
side = 0
prev_v = np.nan
for t in range(len(prices)):
v = vg.iloc[t]
if np.isnan(v):
pos.iloc[t] = side
prev_v = v
continue
if not np.isnan(prev_v):
if prev_v <= low_thr and v > low_thr:
side = 1
elif prev_v >= high_thr and v < high_thr:
side = -1
pos.iloc[t] = side
prev_v = v
ret = prices.pct_change().fillna(0)
pnl = pos.shift(1).fillna(0) * ret
# Trừ cost khi đảo chiều
flips = (pos.diff().abs() > 0).astype(int)
pnl -= flips * cost_bps / 10_000
eq = (1 + pnl).cumprod()
return pd.DataFrame(
{
"price": prices,
"vgrsi": vg,
"position": pos,
"pnl": pnl,
"equity": eq,
}
)
def perf_metrics(eq: pd.Series, periods_per_year: int = 252) -> dict:
"""Tính Sharpe, max drawdown, CAGR đơn giản."""
r = eq.pct_change().dropna()
if r.std() == 0:
return {"sharpe": 0.0, "max_dd": 0.0, "cagr": 0.0}
sharpe = r.mean() / r.std() * np.sqrt(periods_per_year)
dd = (eq / eq.cummax() - 1).min()
n_years = len(r) / periods_per_year
cagr = eq.iloc[-1] ** (1 / max(n_years, 1e-6)) - 1
return {"sharpe": float(sharpe), "max_dd": float(dd), "cagr": float(cagr)}
if __name__ == "__main__":
# Sinh chuỗi giá giả lập (GBM với drift nhẹ) cho demo
rng = np.random.default_rng(123)
n = 500
idx = pd.bdate_range("2024-05-02", periods=n)
log_r = rng.normal(0.0003, 0.012, n)
price = pd.Series(100.0 * np.exp(np.cumsum(log_r)), index=idx, name="px")
bt = vgrsi_strategy_backtest(
price, N=14, window=30, low_thr=30, high_thr=70, cost_bps=5.0
)
m = perf_metrics(bt["equity"])
print("VGRSI demo (random GBM):")
print(f" Final equity: {bt['equity'].iloc[-1]:.3f}")
print(f" Sharpe (annualised): {m['sharpe']:.2f}")
print(f" Max drawdown: {m['max_dd']*100:.1f}%")
print(f" CAGR: {m['cagr']*100:.1f}%")
print(f" # position flips: {(bt['position'].diff().abs() > 0).sum()}")
# Trên GBM random: Sharpe gần 0 — đúng kỳ vọng cho noise process.