feat: Ground Truth 차트 v1/v2/v3 분리 및 차트 UI 개선
스윙만(v1), 눌림목(v2), 전체 신호(v3)로 GT를 단계별 생성하고 마커·Y축 가독성을 개선한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,14 @@ from deepcoin.data.intervals import DEFAULT_DOWNLOAD_INTERVALS
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _resolve_project_path(raw: str) -> Path:
|
||||
"""프로젝트 루트 기준 상대 경로를 절대 경로로 변환한다."""
|
||||
path = Path(raw)
|
||||
if not path.is_absolute():
|
||||
path = _PROJECT_ROOT / path
|
||||
return path
|
||||
|
||||
|
||||
def _parse_int_list(raw: str) -> list[int]:
|
||||
"""쉼표 구분 정수 목록을 파싱한다.
|
||||
|
||||
@@ -55,7 +63,11 @@ class Settings:
|
||||
gt_div_min_rsi_diff: float
|
||||
gt_div_min_future_move_pct: float
|
||||
ground_truth_file: Path
|
||||
ground_truth_chart_file: Path
|
||||
ground_truth_v1_file: Path
|
||||
ground_truth_v2_file: Path
|
||||
ground_truth_chart_v1_file: Path
|
||||
ground_truth_chart_v2_file: Path
|
||||
ground_truth_chart_v3_file: Path
|
||||
gt_initial_cash_krw: float
|
||||
gt_trading_fee_rate: float
|
||||
# Techniques (2단계)
|
||||
@@ -91,14 +103,24 @@ def load_settings(env_path: Path | None = None) -> Settings:
|
||||
if not db_path.is_absolute():
|
||||
db_path = _PROJECT_ROOT / db_path
|
||||
|
||||
gt_file_raw = os.getenv("GROUND_TRUTH_FILE", "data/ground_truth/ground_truth_trades.json")
|
||||
gt_chart_raw = os.getenv("GROUND_TRUTH_CHART_FILE", "docs/02_ground_truth/ground_truth_chart.html")
|
||||
gt_file = Path(gt_file_raw)
|
||||
gt_chart = Path(gt_chart_raw)
|
||||
if not gt_file.is_absolute():
|
||||
gt_file = _PROJECT_ROOT / gt_file
|
||||
if not gt_chart.is_absolute():
|
||||
gt_chart = _PROJECT_ROOT / gt_chart
|
||||
gt_file = _resolve_project_path(
|
||||
os.getenv("GROUND_TRUTH_FILE", "data/ground_truth/ground_truth_trades_v3.json")
|
||||
)
|
||||
gt_v1_file = _resolve_project_path(
|
||||
os.getenv("GROUND_TRUTH_V1_FILE", "data/ground_truth/ground_truth_trades_v1.json")
|
||||
)
|
||||
gt_v2_file = _resolve_project_path(
|
||||
os.getenv("GROUND_TRUTH_V2_FILE", "data/ground_truth/ground_truth_trades_v2.json")
|
||||
)
|
||||
gt_chart_v1 = _resolve_project_path(
|
||||
os.getenv("GROUND_TRUTH_CHART_V1_FILE", "docs/02_ground_truth/ground_truth_chart_v1.html")
|
||||
)
|
||||
gt_chart_v2 = _resolve_project_path(
|
||||
os.getenv("GROUND_TRUTH_CHART_V2_FILE", "docs/02_ground_truth/ground_truth_chart_v2.html")
|
||||
)
|
||||
gt_chart_v3 = _resolve_project_path(
|
||||
os.getenv("GROUND_TRUTH_CHART_V3_FILE", "docs/02_ground_truth/ground_truth_chart_v3.html")
|
||||
)
|
||||
|
||||
tech_dir_raw = os.getenv("TECHNIQUES_DIR", "data/techniques")
|
||||
tech_dir = Path(tech_dir_raw)
|
||||
@@ -138,7 +160,11 @@ def load_settings(env_path: Path | None = None) -> Settings:
|
||||
gt_div_min_rsi_diff=float(os.getenv("GT_DIV_MIN_RSI_DIFF", "5.0")),
|
||||
gt_div_min_future_move_pct=float(os.getenv("GT_DIV_MIN_FUTURE_MOVE_PCT", "4.0")),
|
||||
ground_truth_file=gt_file,
|
||||
ground_truth_chart_file=gt_chart,
|
||||
ground_truth_v1_file=gt_v1_file,
|
||||
ground_truth_v2_file=gt_v2_file,
|
||||
ground_truth_chart_v1_file=gt_chart_v1,
|
||||
ground_truth_chart_v2_file=gt_chart_v2,
|
||||
ground_truth_chart_v3_file=gt_chart_v3,
|
||||
gt_initial_cash_krw=float(os.getenv("GT_INITIAL_CASH_KRW", "400000")),
|
||||
gt_trading_fee_rate=float(os.getenv("GT_TRADING_FEE_RATE", "0.0005")),
|
||||
techniques_dir=tech_dir,
|
||||
|
||||
@@ -15,8 +15,11 @@ DEFAULT_MAX_CANDLES = 0
|
||||
|
||||
|
||||
def _data_js_path(html_path: Path) -> Path:
|
||||
"""HTML과 짝을 이루는 데이터 JS 경로 (file:// 프로토콜 호환)."""
|
||||
return html_path.with_name("ground_truth_chart_data.js")
|
||||
"""HTML과 짝을 이루는 데이터 JS 경로 (file:// 프로토콜 호환).
|
||||
|
||||
예: ground_truth_chart_v3.html → ground_truth_chart_v3_data.js
|
||||
"""
|
||||
return html_path.with_name(f"{html_path.stem}_data.js")
|
||||
|
||||
|
||||
def render_ground_truth_chart(
|
||||
@@ -95,7 +98,8 @@ def render_ground_truth_chart(
|
||||
json.dump(payload, fp, ensure_ascii=False, separators=(",", ":"))
|
||||
fp.write(";")
|
||||
|
||||
output_path.write_text(_HTML_TEMPLATE, encoding="utf-8")
|
||||
data_js_name = data_path.name
|
||||
output_path.write_text(_html_template(data_js_name), encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
|
||||
@@ -107,7 +111,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="ground_truth_chart_data.js"></script>
|
||||
<script src="__DATA_JS_NAME__"></script>
|
||||
<style>
|
||||
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
|
||||
header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #ddd; }
|
||||
@@ -123,10 +127,11 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
.toolbar button.home:hover { background: #1b5e20; }
|
||||
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
|
||||
#status { font-size: 12px; color: #888; margin-left: auto; }
|
||||
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; }
|
||||
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
|
||||
#detail-wrap { margin: 0 24px 12px; display: none; }
|
||||
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
|
||||
#detail { height: 360px; background: #fff; border: 1px solid #ddd; }
|
||||
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -175,10 +180,41 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
let detailVisible = false;
|
||||
let lastDetailStart = 0;
|
||||
|
||||
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||
let axisMeasureCtx = null;
|
||||
|
||||
function fmtPrice(v) {
|
||||
return Math.round(v).toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
function measureTextWidth(text, font) {
|
||||
if (!axisMeasureCtx) {
|
||||
const c = document.createElement("canvas");
|
||||
axisMeasureCtx = c.getContext("2d");
|
||||
}
|
||||
axisMeasureCtx.font = font;
|
||||
return axisMeasureCtx.measureText(text).width;
|
||||
}
|
||||
|
||||
function yAxisLabelWidth() {
|
||||
const vals = DATA.close;
|
||||
if (!vals || !vals.length) return 88;
|
||||
const samples = new Set([vals[0], vals[vals.length - 1]]);
|
||||
let lo = vals[0], hi = vals[0];
|
||||
for (let i = 1; i < vals.length; i++) {
|
||||
if (vals[i] < lo) lo = vals[i];
|
||||
if (vals[i] > hi) hi = vals[i];
|
||||
}
|
||||
samples.add(lo);
|
||||
samples.add(hi);
|
||||
samples.add((lo + hi) / 2);
|
||||
let maxW = 0;
|
||||
samples.forEach(v => {
|
||||
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
|
||||
});
|
||||
return Math.ceil(maxW) + 20;
|
||||
}
|
||||
|
||||
function updateLegInfo() {
|
||||
const total = DATA.buy_markers.length;
|
||||
const el = document.getElementById("leg-info");
|
||||
@@ -186,6 +222,27 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
|
||||
}
|
||||
|
||||
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
|
||||
|
||||
function markerSuffix(signalType) {
|
||||
if (signalType === "pullback") return "*";
|
||||
if (signalType === "breakout") return "^";
|
||||
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||
return "";
|
||||
}
|
||||
|
||||
function drawMarkerLabel(ctx, label, x, y, color, up) {
|
||||
ctx.font = MARKER_FONT;
|
||||
const lx = x + 10;
|
||||
const ly = y + (up ? 28 : -20);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.strokeText(label, lx, ly);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, lx, ly);
|
||||
}
|
||||
|
||||
function drawMarkers(u, buys, sells) {
|
||||
if (!showMarkers) return;
|
||||
const ctx = u.ctx;
|
||||
@@ -195,21 +252,16 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
const s = 5;
|
||||
const s = 8;
|
||||
const gap = 12;
|
||||
if (up) {
|
||||
ctx.moveTo(x, y + 10); ctx.lineTo(x - s, y + 18); ctx.lineTo(x + s, y + 18);
|
||||
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
|
||||
} else {
|
||||
ctx.moveTo(x, y - 10); ctx.lineTo(x - s, y - 18); ctx.lineTo(x + s, y - 18);
|
||||
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.font = "10px Malgun Gothic, Arial";
|
||||
let suffix = "";
|
||||
if (m.signal_type === "pullback") suffix = "*";
|
||||
else if (m.signal_type === "breakout") suffix = "^";
|
||||
else if (m.signal_type === "div_bull" || m.signal_type === "div_bear") suffix = "d";
|
||||
const label = (up ? "B" : "S") + m.marker_id + suffix;
|
||||
ctx.fillText(label, x + 6, y + (up ? 14 : -12));
|
||||
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
|
||||
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||
};
|
||||
buys.forEach(m => {
|
||||
let color = "#2e7d32";
|
||||
@@ -252,13 +304,21 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
function buildOverview(keepRange) {
|
||||
const prev = keepRange ? overviewXRange() : null;
|
||||
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
|
||||
const yAxisW = yAxisLabelWidth();
|
||||
const opts = {
|
||||
width: document.getElementById("overview").clientWidth,
|
||||
height: 480,
|
||||
padding: [14, 10, 14, 10],
|
||||
scales: { x: { time: true } },
|
||||
axes: [
|
||||
{},
|
||||
{ values: (u, vals) => vals.map(v => fmtPrice(v)) },
|
||||
{ gap: 6 },
|
||||
{
|
||||
side: 3,
|
||||
size: yAxisW,
|
||||
gap: 10,
|
||||
font: AXIS_FONT,
|
||||
values: (u, vals) => vals.map(v => fmtPrice(v)),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
@@ -289,14 +349,22 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||
const wrap = document.getElementById("detail");
|
||||
wrap.innerHTML = "";
|
||||
const priceAxisW = yAxisLabelWidth();
|
||||
detailChart = LightweightCharts.createChart(wrap, {
|
||||
layout: { background: { color: "#fff" }, textColor: "#333" },
|
||||
layout: { background: { color: "#fff" }, textColor: "#333", fontSize: 14 },
|
||||
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
|
||||
rightPriceScale: { visible: false },
|
||||
leftPriceScale: {
|
||||
borderVisible: true,
|
||||
minimumWidth: priceAxisW,
|
||||
scaleMargins: { top: 0.08, bottom: 0.08 },
|
||||
},
|
||||
timeScale: { timeVisible: true, secondsVisible: false },
|
||||
width: wrap.clientWidth,
|
||||
height: 360,
|
||||
});
|
||||
detailSeries = detailChart.addCandlestickSeries({
|
||||
priceScaleId: "left",
|
||||
upColor: "#c62828", downColor: "#1565c0",
|
||||
borderUpColor: "#c62828", borderDownColor: "#1565c0",
|
||||
wickUpColor: "#c62828", wickDownColor: "#1565c0",
|
||||
@@ -319,17 +387,16 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
time: m.time, position: "belowBar",
|
||||
color: m.signal_type === "breakout" ? "#ef6c00"
|
||||
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
|
||||
shape: "arrowUp",
|
||||
text: "B" + m.marker_id + (m.signal_type === "pullback" ? "*"
|
||||
: m.signal_type === "breakout" ? "^" : m.signal_type === "div_bull" ? "d" : ""),
|
||||
shape: "arrowUp", size: 3,
|
||||
text: "B" + m.marker_id + markerSuffix(m.signal_type),
|
||||
});
|
||||
});
|
||||
DATA.sell_markers.forEach(m => {
|
||||
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||
time: m.time, position: "aboveBar",
|
||||
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
|
||||
shape: "arrowDown",
|
||||
text: "S" + m.marker_id + (m.signal_type === "div_bear" ? "d" : ""),
|
||||
shape: "arrowDown", size: 3,
|
||||
text: "S" + m.marker_id + markerSuffix(m.signal_type),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -438,19 +505,26 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
|
||||
function init() {
|
||||
DATA = window.CHART_DATA;
|
||||
if (!DATA) throw new Error("ground_truth_chart_data.js 없음");
|
||||
if (!DATA) throw new Error("차트 데이터 JS 없음");
|
||||
const m = DATA.meta;
|
||||
const chartDays = m.chart_lookback_days || m.lookback_days;
|
||||
const gtDays = m.gt_lookback_days || m.lookback_days;
|
||||
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`;
|
||||
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`;
|
||||
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
|
||||
document.getElementById("title").textContent =
|
||||
`${m.symbol} Ground Truth (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
|
||||
`${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
|
||||
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
|
||||
const chartFrom = m.chart_data_from || m.data_from;
|
||||
const chartTo = m.chart_data_to || m.data_to;
|
||||
const tierKey = (m.chart_tier || "v3").toLowerCase();
|
||||
const legend = tierKey === "v1"
|
||||
? "B=스윙매수 S=스윙매도"
|
||||
: tierKey === "v2"
|
||||
? "B/S=스윙 B*=눌림목"
|
||||
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
|
||||
document.getElementById("meta").textContent =
|
||||
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | B*=눌림 B^=돌파 Bd/Sd=다이버전스`;
|
||||
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | ${legend}`;
|
||||
updateLegInfo();
|
||||
document.getElementById("status").textContent =
|
||||
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||
@@ -498,3 +572,8 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _html_template(data_js_name: str) -> str:
|
||||
"""차트 HTML 템플릿을 생성한다."""
|
||||
return _HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name)
|
||||
|
||||
@@ -36,6 +36,22 @@ class GtParams:
|
||||
div_min_bars_between: int = 1500
|
||||
div_min_rsi_diff: float = 5.0
|
||||
div_min_future_move_pct: float = 4.0
|
||||
chart_tier: str = "v3"
|
||||
|
||||
|
||||
def _tier_flags(tier: str) -> tuple[bool, bool, bool]:
|
||||
"""차트 버전별 보조 신호 포함 여부 (눌림목, 돌파, 다이버전스).
|
||||
|
||||
v1: ZigZag 스윙만 (레그당 1매수·1매도 최소)
|
||||
v2: 스윙 + 눌림목
|
||||
v3: v2 + 돌파 + 다이버전스
|
||||
"""
|
||||
tier = tier.lower()
|
||||
if tier == "v1":
|
||||
return False, False, False
|
||||
if tier == "v2":
|
||||
return True, False, False
|
||||
return True, True, True
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -87,27 +103,46 @@ def build_ground_truth(
|
||||
pivots = find_zigzag_pivots(df, reversal_pct=params.zigzag_reversal_pct)
|
||||
legs = _pivots_to_legs(pivots, min_leg_pct=params.min_leg_pct)
|
||||
leg_dicts = [asdict(leg) for leg in legs]
|
||||
pullback_buys = find_pullback_buy_pivots(
|
||||
df,
|
||||
legs=legs,
|
||||
min_pullback_pct=params.pullback_min_pct,
|
||||
local_order=params.pullback_local_order,
|
||||
)
|
||||
breakout_buys = find_breakout_buy_pivots(
|
||||
df,
|
||||
legs=legs,
|
||||
pullback_buys=pullback_buys,
|
||||
breakout_buffer_pct=params.breakout_buffer_pct,
|
||||
consolidation_bars=params.breakout_consolidation_bars,
|
||||
min_rally_to_sell_pct=params.breakout_min_rally_pct,
|
||||
)
|
||||
div_buys, div_sells = find_divergence_signals(
|
||||
df,
|
||||
local_order=params.div_local_order,
|
||||
min_bars_between=params.div_min_bars_between,
|
||||
min_rsi_diff=params.div_min_rsi_diff,
|
||||
min_future_move_pct=params.div_min_future_move_pct,
|
||||
)
|
||||
include_pullback, include_breakout, include_divergence = _tier_flags(params.chart_tier)
|
||||
|
||||
pullback_buys: list[Pivot] = []
|
||||
if include_pullback:
|
||||
pullback_buys = find_pullback_buy_pivots(
|
||||
df,
|
||||
legs=legs,
|
||||
min_pullback_pct=params.pullback_min_pct,
|
||||
local_order=params.pullback_local_order,
|
||||
)
|
||||
|
||||
breakout_buys = []
|
||||
if include_breakout:
|
||||
breakout_buys = find_breakout_buy_pivots(
|
||||
df,
|
||||
legs=legs,
|
||||
pullback_buys=pullback_buys,
|
||||
breakout_buffer_pct=params.breakout_buffer_pct,
|
||||
consolidation_bars=params.breakout_consolidation_bars,
|
||||
min_rally_to_sell_pct=params.breakout_min_rally_pct,
|
||||
)
|
||||
|
||||
div_buys: list = []
|
||||
div_sells: list = []
|
||||
if include_divergence:
|
||||
div_buys, div_sells = find_divergence_signals(
|
||||
df,
|
||||
local_order=params.div_local_order,
|
||||
min_bars_between=params.div_min_bars_between,
|
||||
min_rsi_diff=params.div_min_rsi_diff,
|
||||
min_future_move_pct=params.div_min_future_move_pct,
|
||||
)
|
||||
|
||||
mode_map = {
|
||||
"v1": "optimal_swing_legs",
|
||||
"v2": "optimal_swing_legs_with_pullback",
|
||||
"v3": "optimal_swing_legs_with_pullback_breakout_divergence",
|
||||
}
|
||||
mode = mode_map.get(params.chart_tier.lower(), mode_map["v3"])
|
||||
|
||||
signals = _build_signals(legs, pullback_buys, breakout_buys, div_buys, div_sells)
|
||||
summary = _summarize(legs, signals)
|
||||
pnl = simulate_gt_pnl(leg_dicts, initial_cash_krw=initial_cash_krw, fee_rate=fee_rate)
|
||||
@@ -119,7 +154,8 @@ def build_ground_truth(
|
||||
"interval_min": params.interval_min,
|
||||
"interval_label": interval_label(params.interval_min),
|
||||
"lookback_days": params.lookback_days,
|
||||
"mode": "optimal_swing_legs_with_pullback_breakout_divergence",
|
||||
"chart_tier": params.chart_tier.lower(),
|
||||
"mode": mode,
|
||||
"zigzag_reversal_pct": params.zigzag_reversal_pct,
|
||||
"min_leg_pct": params.min_leg_pct,
|
||||
"pullback_min_pct": params.pullback_min_pct,
|
||||
|
||||
Reference in New Issue
Block a user