diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py
index 5b7dd2d..0dca753 100644
--- a/crypto_monitor_gate_bot/app.py
+++ b/crypto_monitor_gate_bot/app.py
@@ -35,6 +35,18 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from ai_client import ai_generate, ai_review, ai_short_advice
+from journal_chart_lib import (
+ JOURNAL_CHART_DEFAULT_LIMIT,
+ JOURNAL_CHART_DEFAULT_TF1,
+ JOURNAL_CHART_DEFAULT_TF2,
+ JOURNAL_CHART_TF_CHOICES,
+ compose_chart_panels,
+ marker_points_for_timeframe,
+ parse_journal_chart_limit,
+ parse_journal_chart_timeframes,
+ price_levels_from_marker_payload,
+ render_candles_subplot,
+)
from hub_auth import request_allowed as hub_request_allowed
from history_window_lib import (
PRESET_CUSTOM,
@@ -770,20 +782,24 @@ def generate_multi_timeframe_chart_png(
filename_prefix="chart",
marker_payload=None,
marker_timeframes=None,
+ layout="grid",
):
if not ORDER_CHART_ENABLED:
return None
if not Image:
return None
- requested = timeframes or ORDER_CHART_TFS
+ requested = list(timeframes or ORDER_CHART_TFS)
limit = limit or ORDER_CHART_LIMIT
- preferred_layout = ["5m", "15m", "1h", "4h"]
- requested_set = set(requested or [])
- ordered = [tf for tf in preferred_layout if tf in requested_set]
- for tf in requested:
- if tf not in ordered:
- ordered.append(tf)
- timeframes = ordered[:4] if ordered else preferred_layout
+ if layout == "vertical":
+ timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
+ else:
+ preferred_layout = ["5m", "15m", "1h", "4h"]
+ requested_set = set(requested or [])
+ ordered = [tf for tf in preferred_layout if tf in requested_set]
+ for tf in requested:
+ if tf not in ordered:
+ ordered.append(tf)
+ timeframes = ordered[:4] if ordered else preferred_layout
ensure_markets_loaded()
panels = []
@@ -795,6 +811,7 @@ def generate_multi_timeframe_chart_png(
except (TypeError, ValueError):
end_ts_ms = None
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
+ price_levels = price_levels_from_marker_payload(marker_payload)
for tf in timeframes:
try:
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
@@ -804,7 +821,6 @@ def generate_multi_timeframe_chart_png(
ohlcv = []
rows = _ohlcv_to_rows(ohlcv)[-limit:]
title = f"{title_prefix} | {tf} x{len(rows)}"
- points = []
tf_key = str(tf).strip().lower()
if marker_payload:
if marker_timeframes:
@@ -813,54 +829,29 @@ def generate_multi_timeframe_chart_png(
marker_tfs = default_marker_tfs
else:
marker_tfs = set()
- if marker_payload and tf_key in marker_tfs:
- entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price"))
- exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price"))
- if entry_idx is not None and entry_price is not None:
- points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"})
- if exit_idx is not None and exit_price is not None:
- points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"})
+ points = (
+ marker_points_for_timeframe(rows, marker_payload)
+ if marker_payload and tf_key in marker_tfs
+ else []
+ )
panels.append(
- _render_candles_subplot(
+ render_candles_subplot(
rows,
title,
width=cell_w,
height=cell_h,
bg_rgb=(255, 255, 255),
marker_points=points,
+ price_levels=price_levels,
)
)
if not panels:
return None
- gap = 10
- cols = 2
- rows_n = int(math.ceil(len(panels) / cols))
- w = cols * cell_w + (cols - 1) * gap
- h = rows_n * cell_h + (rows_n - 1) * gap
- out = Image.new("RGB", (w, h), (255, 255, 255))
- idx = 0
- for r in range(rows_n):
- for c in range(cols):
- if idx >= len(panels):
- break
- x = c * (cell_w + gap)
- y = r * (cell_h + gap)
- out.paste(panels[idx], (x, y))
- idx += 1
-
- # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图)
- if ImageDraw and rows_n >= 1:
- draw_out = ImageDraw.Draw(out)
- line_col = (220, 225, 232)
- x_mid = cell_w + gap // 2
- if w > x_mid >= 0:
- draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2)
- for rr in range(1, rows_n):
- y_mid = rr * cell_h + (rr - 1) * gap + gap // 2
- if 0 <= y_mid <= h:
- draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2)
+ out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10)
+ if out is None:
+ return None
target_dir = out_dir or ORDER_CHART_DIR
os.makedirs(target_dir, exist_ok=True)
@@ -5323,6 +5314,10 @@ def render_main_page(page="trade"):
money_fmt=format_money_usdt,
entry_reason_options=list(ENTRY_REASON_OPTIONS),
entry_reason_other_value=ENTRY_REASON_OTHER,
+ journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES,
+ journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1,
+ journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2,
+ journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT,
exchange_display=EXCHANGE_DISPLAY_NAME,
**strategy_extra,
)
@@ -6839,32 +6834,36 @@ def add_journal():
symbol_guess = normalize_symbol_input(coin) or coin
exchange_symbol = normalize_exchange_symbol(symbol_guess)
title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
+ journal_tfs = parse_journal_chart_timeframes(
+ d.get("journal_chart_tf1"),
+ d.get("journal_chart_tf2"),
+ ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None,
+ )
+ journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT)
marker_payload = {
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
"entry_price": d.get("entry_price_hint"),
- "exit_price": None,
+ "exit_price": d.get("exit_price_hint"),
+ "stop_loss_price": d.get("stop_loss_hint"),
}
try:
chart_fname = f"journal_{entry_id}.png"
saved = generate_multi_timeframe_chart_png(
exchange_symbol,
title_prefix,
- timeframes=ORDER_CHART_TFS,
- limit=ORDER_CHART_LIMIT,
+ timeframes=journal_tfs,
+ limit=journal_limit,
out_dir=app.config["UPLOAD_FOLDER"],
filename=chart_fname,
filename_prefix="journal",
marker_payload=marker_payload,
- marker_timeframes=(
- {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()}
- if ORDER_CHART_TFS
- else {"5m", "15m", "1h", "4h"}
- ),
+ marker_timeframes={x.strip().lower() for x in journal_tfs},
+ layout="vertical",
)
if saved:
image_filename = saved
- chart_msg = f"已生成多周期K线图:/static/images/{saved}"
+ chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}"
if uploaded_tmp:
try:
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html
index c742305..b65859b 100644
--- a/crypto_monitor_gate_bot/templates/index.html
+++ b/crypto_monitor_gate_bot/templates/index.html
@@ -483,6 +483,7 @@
+
+
+
+
+
+
+
+
双周期上下排列;以平仓时间为锚点向前取 K 线;标注开仓、平仓与止损位
diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py
index b64fa11..fce693f 100644
--- a/crypto_monitor_okx/app.py
+++ b/crypto_monitor_okx/app.py
@@ -46,6 +46,18 @@ from fib_key_monitor_lib import (
stored_key_signal_type,
)
from okx_orders_lib import fetch_okx_all_open_orders
+from journal_chart_lib import (
+ JOURNAL_CHART_DEFAULT_LIMIT,
+ JOURNAL_CHART_DEFAULT_TF1,
+ JOURNAL_CHART_DEFAULT_TF2,
+ JOURNAL_CHART_TF_CHOICES,
+ compose_chart_panels,
+ marker_points_for_timeframe,
+ parse_journal_chart_limit,
+ parse_journal_chart_timeframes,
+ price_levels_from_marker_payload,
+ render_candles_subplot,
+)
from key_sl_tp_lib import (
breakeven_enabled_from_row,
normalize_sl_tp_mode,
@@ -763,20 +775,24 @@ def generate_multi_timeframe_chart_png(
filename_prefix="chart",
marker_payload=None,
marker_timeframes=None,
+ layout="grid",
):
if not ORDER_CHART_ENABLED:
return None
if not Image:
return None
- requested = timeframes or ORDER_CHART_TFS
+ requested = list(timeframes or ORDER_CHART_TFS)
limit = limit or ORDER_CHART_LIMIT
- preferred_layout = ["5m", "15m", "1h", "4h"]
- requested_set = set(requested or [])
- ordered = [tf for tf in preferred_layout if tf in requested_set]
- for tf in requested:
- if tf not in ordered:
- ordered.append(tf)
- timeframes = ordered[:4] if ordered else preferred_layout
+ if layout == "vertical":
+ timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
+ else:
+ preferred_layout = ["5m", "15m", "1h", "4h"]
+ requested_set = set(requested or [])
+ ordered = [tf for tf in preferred_layout if tf in requested_set]
+ for tf in requested:
+ if tf not in ordered:
+ ordered.append(tf)
+ timeframes = ordered[:4] if ordered else preferred_layout
ensure_markets_loaded()
panels = []
@@ -788,6 +804,7 @@ def generate_multi_timeframe_chart_png(
except (TypeError, ValueError):
end_ts_ms = None
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
+ price_levels = price_levels_from_marker_payload(marker_payload)
for tf in timeframes:
try:
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
@@ -797,7 +814,6 @@ def generate_multi_timeframe_chart_png(
ohlcv = []
rows = _ohlcv_to_rows(ohlcv)[-limit:]
title = f"{title_prefix} | {tf} x{len(rows)}"
- points = []
tf_key = str(tf).strip().lower()
if marker_payload:
if marker_timeframes:
@@ -806,54 +822,29 @@ def generate_multi_timeframe_chart_png(
marker_tfs = default_marker_tfs
else:
marker_tfs = set()
- if marker_payload and tf_key in marker_tfs:
- entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price"))
- exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price"))
- if entry_idx is not None and entry_price is not None:
- points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"})
- if exit_idx is not None and exit_price is not None:
- points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"})
+ points = (
+ marker_points_for_timeframe(rows, marker_payload)
+ if marker_payload and tf_key in marker_tfs
+ else []
+ )
panels.append(
- _render_candles_subplot(
+ render_candles_subplot(
rows,
title,
width=cell_w,
height=cell_h,
bg_rgb=(255, 255, 255),
marker_points=points,
+ price_levels=price_levels,
)
)
if not panels:
return None
- gap = 10
- cols = 2
- rows_n = int(math.ceil(len(panels) / cols))
- w = cols * cell_w + (cols - 1) * gap
- h = rows_n * cell_h + (rows_n - 1) * gap
- out = Image.new("RGB", (w, h), (255, 255, 255))
- idx = 0
- for r in range(rows_n):
- for c in range(cols):
- if idx >= len(panels):
- break
- x = c * (cell_w + gap)
- y = r * (cell_h + gap)
- out.paste(panels[idx], (x, y))
- idx += 1
-
- # 四宫格间隔线(仅在拼图间隙处画线,不进入单张子图)
- if ImageDraw and rows_n >= 1:
- draw_out = ImageDraw.Draw(out)
- line_col = (220, 225, 232)
- x_mid = cell_w + gap // 2
- if w > x_mid >= 0:
- draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2)
- for rr in range(1, rows_n):
- y_mid = rr * cell_h + (rr - 1) * gap + gap // 2
- if 0 <= y_mid <= h:
- draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2)
+ out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10)
+ if out is None:
+ return None
target_dir = out_dir or ORDER_CHART_DIR
os.makedirs(target_dir, exist_ok=True)
@@ -5208,6 +5199,10 @@ def render_main_page(page="trade"):
price_fmt=format_price_for_symbol,
entry_reason_options=list(ENTRY_REASON_OPTIONS),
entry_reason_other_value=ENTRY_REASON_OTHER,
+ journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES,
+ journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1,
+ journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2,
+ journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT,
key_gate_rule_text=key_gate_rule_text,
funds_fmt=format_funds_u,
exchange_display=EXCHANGE_DISPLAY_NAME,
@@ -6747,32 +6742,36 @@ def add_journal():
symbol_guess = normalize_symbol_input(coin) or coin
exchange_symbol = normalize_okx_symbol(symbol_guess)
title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
+ journal_tfs = parse_journal_chart_timeframes(
+ d.get("journal_chart_tf1"),
+ d.get("journal_chart_tf2"),
+ ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None,
+ )
+ journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT)
marker_payload = {
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
"entry_price": d.get("entry_price_hint"),
- "exit_price": None,
+ "exit_price": d.get("exit_price_hint"),
+ "stop_loss_price": d.get("stop_loss_hint"),
}
try:
chart_fname = f"journal_{entry_id}.png"
saved = generate_multi_timeframe_chart_png(
exchange_symbol,
title_prefix,
- timeframes=ORDER_CHART_TFS,
- limit=ORDER_CHART_LIMIT,
+ timeframes=journal_tfs,
+ limit=journal_limit,
out_dir=app.config["UPLOAD_FOLDER"],
filename=chart_fname,
filename_prefix="journal",
marker_payload=marker_payload,
- marker_timeframes=(
- {x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()}
- if ORDER_CHART_TFS
- else {"5m", "15m", "1h", "4h"}
- ),
+ marker_timeframes={x.strip().lower() for x in journal_tfs},
+ layout="vertical",
)
if saved:
image_filename = saved
- chart_msg = f"已生成多周期K线图:/static/images/{saved}"
+ chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}"
if uploaded_tmp:
try:
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html
index 8663265..fa7c9f2 100644
--- a/crypto_monitor_okx/templates/index.html
+++ b/crypto_monitor_okx/templates/index.html
@@ -666,6 +666,7 @@
+
@@ -697,12 +698,31 @@
-
+
+
+
+
+
+
+
+
双周期上下排列;以平仓时间为锚点向前取 K 线;标注开仓、平仓与止损位
diff --git a/journal_chart_lib.py b/journal_chart_lib.py
new file mode 100644
index 0000000..43dd059
--- /dev/null
+++ b/journal_chart_lib.py
@@ -0,0 +1,335 @@
+"""交易复盘 / 订单 K 线拼图(Binance / Gate / OKX 共用)。"""
+
+import math
+
+try:
+ from PIL import Image, ImageDraw, ImageFont
+except ImportError:
+ Image = None # type: ignore
+ ImageDraw = None # type: ignore
+ ImageFont = None # type: ignore
+
+JOURNAL_CHART_TF_CHOICES = ("1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1d")
+JOURNAL_CHART_DEFAULT_TF1 = "15m"
+JOURNAL_CHART_DEFAULT_TF2 = "1h"
+JOURNAL_CHART_DEFAULT_LIMIT = 300
+JOURNAL_CHART_LIMIT_MIN = 50
+JOURNAL_CHART_LIMIT_MAX = 500
+
+
+def _load_font(size):
+ if not ImageFont:
+ return None
+ for name in ("msyh.ttc", "Microsoft YaHei.ttf", "arial.ttf", "Arial.ttf"):
+ try:
+ return ImageFont.truetype(name, size)
+ except Exception:
+ continue
+ try:
+ return ImageFont.load_default()
+ except Exception:
+ return None
+
+
+def ohlcv_to_rows(ohlcv):
+ rows = []
+ for bar in ohlcv or []:
+ if not bar or len(bar) < 6:
+ continue
+ try:
+ rows.append(
+ {
+ "ts": int(bar[0]),
+ "o": float(bar[1]),
+ "h": float(bar[2]),
+ "l": float(bar[3]),
+ "c": float(bar[4]),
+ "v": float(bar[5]),
+ }
+ )
+ except Exception:
+ continue
+ return rows
+
+
+def marker_tag_label(tag):
+ t = str(tag or "").strip().upper()
+ if t == "ENTRY":
+ return "开仓"
+ if t == "EXIT":
+ return "平仓"
+ if t == "STOP":
+ return "止损"
+ return str(tag or "")
+
+
+def pick_marker_point(rows, target_ts_ms, target_price=None):
+ if not rows or target_ts_ms is None:
+ return None, None
+ idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms)))
+ if target_price is not None:
+ try:
+ p = float(target_price)
+ if p > 0:
+ return idx, p
+ except Exception:
+ pass
+ return idx, float(rows[idx]["c"])
+
+
+def parse_positive_price(raw):
+ if raw is None:
+ return None
+ s = str(raw).strip()
+ if not s:
+ return None
+ try:
+ p = float(s)
+ return p if p > 0 else None
+ except (TypeError, ValueError):
+ return None
+
+
+def parse_journal_chart_limit(raw, fallback=None):
+ fb = int(fallback if fallback is not None else JOURNAL_CHART_DEFAULT_LIMIT)
+ try:
+ n = int(str(raw or "").strip() or fb)
+ except (TypeError, ValueError):
+ n = fb
+ return max(JOURNAL_CHART_LIMIT_MIN, min(JOURNAL_CHART_LIMIT_MAX, n))
+
+
+def normalize_chart_timeframe(raw):
+ tf = str(raw or "").strip().lower()
+ if tf in JOURNAL_CHART_TF_CHOICES:
+ return tf
+ return ""
+
+
+def parse_journal_chart_timeframes(tf1, tf2, fallback_tfs=None):
+ """复盘表单:最多两个周期,去重保序。"""
+ out = []
+ for raw in (tf1, tf2):
+ tf = normalize_chart_timeframe(raw)
+ if tf and tf not in out:
+ out.append(tf)
+ if out:
+ return out[:2]
+ fb = [normalize_chart_timeframe(x) for x in (fallback_tfs or (JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2))]
+ fb = [x for x in fb if x]
+ return fb[:2] if fb else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
+
+
+def marker_points_for_timeframe(rows, marker_payload):
+ points = []
+ if not marker_payload or not rows:
+ return points
+ entry_idx, entry_price = pick_marker_point(
+ rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")
+ )
+ exit_idx, exit_price = pick_marker_point(
+ rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")
+ )
+ if entry_idx is not None and entry_price is not None:
+ points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"})
+ if exit_idx is not None and exit_price is not None:
+ points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"})
+ return points
+
+
+def price_levels_from_marker_payload(marker_payload):
+ levels = []
+ if not marker_payload:
+ return levels
+ sl = parse_positive_price(marker_payload.get("stop_loss_price"))
+ if sl is not None:
+ levels.append({"price": sl, "label": "止损", "color": (255, 152, 0)})
+ return levels
+
+
+def render_candles_subplot(
+ rows,
+ title,
+ width,
+ height,
+ bg_rgb=(255, 255, 255),
+ marker_points=None,
+ price_levels=None,
+):
+ if not Image or not ImageDraw:
+ raise RuntimeError("缺少依赖:Pillow(pip install Pillow)")
+ img = Image.new("RGB", (width, height), bg_rgb)
+ draw = ImageDraw.Draw(img)
+ font = _load_font(14)
+ small = _load_font(12)
+
+ pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28
+ plot_w = max(10, width - pad_l - pad_r)
+ plot_h = max(10, height - pad_t - pad_b)
+
+ header_bg = (245, 247, 250)
+ draw.rectangle((0, 0, width, pad_t), fill=header_bg)
+ if font:
+ draw.text((10, 6), title, fill=(25, 35, 60), font=font)
+ else:
+ draw.text((10, 6), title, fill=(25, 35, 60))
+
+ if not rows:
+ if small:
+ draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small)
+ else:
+ draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120))
+ return img
+
+ lo = min(r["l"] for r in rows)
+ hi = max(r["h"] for r in rows)
+ for pl in price_levels or []:
+ try:
+ p = float(pl.get("price"))
+ if p > 0:
+ lo = min(lo, p)
+ hi = max(hi, p)
+ except (TypeError, ValueError):
+ pass
+ if hi <= lo:
+ hi = lo + 1e-12
+
+ n = len(rows)
+ marker_by_idx = {}
+ for mp in marker_points or []:
+ try:
+ idx = int(mp.get("idx"))
+ except Exception:
+ continue
+ if idx < 0 or idx >= n:
+ continue
+ marker_by_idx.setdefault(idx, []).append(mp)
+
+ x0 = pad_l
+ for i, r in enumerate(rows):
+ x1 = pad_l + int((i + 1) * plot_w / n)
+ x_mid = (x0 + x1) // 2
+ wick_x = x_mid
+ y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h)
+ y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h)
+ y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h)
+ y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h)
+ top = min(y_open, y_close)
+ bot = max(y_open, y_close)
+ up = r["c"] >= r["o"]
+ wick_color = (120, 120, 120)
+ edge_color = (20, 20, 20)
+ draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color)
+ body_w = max(1, (x1 - x0) - 2)
+ left = x0 + 1
+ if bot - top < 2:
+ mid = (top + bot) // 2
+ draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color)
+ else:
+ if up:
+ draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1)
+ else:
+ draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1)
+ for j, mp in enumerate(marker_by_idx.get(i, [])):
+ tag = str(mp.get("tag") or "")
+ label = marker_tag_label(tag)
+ m_price = float(mp.get("price") or r["c"])
+ y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h)
+ y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m))
+ x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14
+ x_draw = int(x_mid + x_off)
+ if tag == "ENTRY":
+ m_color = (0, 195, 95)
+ tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)]
+ text_y = y_m - 36
+ else:
+ m_color = (235, 65, 65)
+ tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)]
+ text_y = y_m + 12
+ draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1)
+ draw.polygon(tri, fill=m_color)
+ draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3)
+ if font:
+ draw.text((x_draw + 8, text_y), label, fill=m_color, font=font)
+ else:
+ draw.text((x_draw + 8, text_y), label, fill=m_color)
+ x0 = x1
+
+ x_right = pad_l + plot_w
+ for pl in price_levels or []:
+ try:
+ p = float(pl.get("price"))
+ except (TypeError, ValueError):
+ continue
+ if p <= 0:
+ continue
+ y_sl = pad_t + int((hi - p) / (hi - lo) * plot_h)
+ color = tuple(pl.get("color") or (255, 152, 0))
+ label = str(pl.get("label") or "止损")
+ for xx in range(pad_l, x_right, 10):
+ draw.line((xx, y_sl, min(xx + 6, x_right), y_sl), fill=color, width=2)
+ if font:
+ draw.text((x_right - 72, y_sl - 18), label, fill=color, font=small or font)
+ else:
+ draw.text((x_right - 72, y_sl - 18), label, fill=color)
+
+ if len(marker_points or []) >= 2:
+ try:
+ entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None)
+ exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None)
+ if entry is not None and exitp is not None:
+ ex_i, ex_p = int(entry["idx"]), float(entry["price"])
+ xx_i, xx_p = int(exitp["idx"]), float(exitp["price"])
+ x_ex = pad_l + int((ex_i + 0.5) * plot_w / n)
+ x_xx = pad_l + int((xx_i + 0.5) * plot_w / n)
+ y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h)
+ y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h)
+ draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3)
+ except Exception:
+ pass
+
+ if small:
+ draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small)
+ return img
+
+
+def compose_chart_panels(panels, layout="grid", cell_w=980, cell_h=520, gap=10):
+ if not panels or not Image:
+ return None
+ if layout == "vertical":
+ cols = 1
+ rows_n = len(panels)
+ else:
+ cols = 2
+ rows_n = int(math.ceil(len(panels) / cols))
+ w = cols * cell_w + (cols - 1) * gap
+ h = rows_n * cell_h + (rows_n - 1) * gap
+ out = Image.new("RGB", (w, h), (255, 255, 255))
+ idx = 0
+ for r in range(rows_n):
+ for c in range(cols):
+ if idx >= len(panels):
+ break
+ x = c * (cell_w + gap)
+ y = r * (cell_h + gap)
+ out.paste(panels[idx], (x, y))
+ idx += 1
+
+ if ImageDraw and layout != "vertical" and rows_n >= 1:
+ draw_out = ImageDraw.Draw(out)
+ line_col = (220, 225, 232)
+ x_mid = cell_w + gap // 2
+ if w > x_mid >= 0:
+ draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2)
+ for rr in range(1, rows_n):
+ y_mid = rr * cell_h + (rr - 1) * gap + gap // 2
+ if 0 <= y_mid <= h:
+ draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2)
+ elif ImageDraw and layout == "vertical" and rows_n >= 2:
+ draw_out = ImageDraw.Draw(out)
+ line_col = (220, 225, 232)
+ for rr in range(1, rows_n):
+ y_mid = rr * cell_h + (rr - 1) * gap + gap // 2
+ if 0 <= y_mid <= h:
+ draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2)
+ return out