diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index fc940e0..53ed3d6 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -45,6 +45,18 @@ from fib_key_monitor_lib import ( key_signal_type_for_trade_record, stored_key_signal_type, ) +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, @@ -799,20 +811,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 = [] @@ -824,6 +840,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) @@ -833,7 +850,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: @@ -842,54 +858,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) @@ -5842,6 +5833,10 @@ def render_main_page(page="trade"): funds_fmt=format_funds_u, 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, max_active_positions=MAX_ACTIVE_POSITIONS, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, @@ -7352,32 +7347,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_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 237fb59..fdbf19c 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -657,6 +657,7 @@ +
@@ -688,12 +689,31 @@
-
+
+ + + + + +
+
双周期上下排列;以平仓时间为锚点向前取 K 线;标注开仓、平仓与止损位
diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 085ae6a..a64d1bb 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -46,6 +46,18 @@ from fib_key_monitor_lib import ( key_signal_type_for_trade_record, stored_key_signal_type, ) +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, @@ -793,20 +805,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 = [] @@ -818,6 +834,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) @@ -827,7 +844,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: @@ -836,54 +852,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) @@ -5849,6 +5840,10 @@ def render_main_page(page="trade"): signed_usdt_fmt=format_signed_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, max_active_positions=MAX_ACTIVE_POSITIONS, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, @@ -7432,32 +7427,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/templates/index.html b/crypto_monitor_gate/templates/index.html index 237fb59..fdbf19c 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -657,6 +657,7 @@ +
@@ -688,12 +689,31 @@
-
+
+ + + + + +
+
双周期上下排列;以平仓时间为锚点向前取 K 线;标注开仓、平仓与止损位
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 @@ +
@@ -514,12 +515,31 @@
-
+
+ + + + + +
+
双周期上下排列;以平仓时间为锚点向前取 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