修改
This commit is contained in:
@@ -45,6 +45,18 @@ from fib_key_monitor_lib import (
|
|||||||
key_signal_type_for_trade_record,
|
key_signal_type_for_trade_record,
|
||||||
stored_key_signal_type,
|
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 (
|
from key_sl_tp_lib import (
|
||||||
breakeven_enabled_from_row,
|
breakeven_enabled_from_row,
|
||||||
normalize_sl_tp_mode,
|
normalize_sl_tp_mode,
|
||||||
@@ -799,20 +811,24 @@ def generate_multi_timeframe_chart_png(
|
|||||||
filename_prefix="chart",
|
filename_prefix="chart",
|
||||||
marker_payload=None,
|
marker_payload=None,
|
||||||
marker_timeframes=None,
|
marker_timeframes=None,
|
||||||
|
layout="grid",
|
||||||
):
|
):
|
||||||
if not ORDER_CHART_ENABLED:
|
if not ORDER_CHART_ENABLED:
|
||||||
return None
|
return None
|
||||||
if not Image:
|
if not Image:
|
||||||
return None
|
return None
|
||||||
requested = timeframes or ORDER_CHART_TFS
|
requested = list(timeframes or ORDER_CHART_TFS)
|
||||||
limit = limit or ORDER_CHART_LIMIT
|
limit = limit or ORDER_CHART_LIMIT
|
||||||
preferred_layout = ["5m", "15m", "1h", "4h"]
|
if layout == "vertical":
|
||||||
requested_set = set(requested or [])
|
timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
|
||||||
ordered = [tf for tf in preferred_layout if tf in requested_set]
|
else:
|
||||||
for tf in requested:
|
preferred_layout = ["5m", "15m", "1h", "4h"]
|
||||||
if tf not in ordered:
|
requested_set = set(requested or [])
|
||||||
ordered.append(tf)
|
ordered = [tf for tf in preferred_layout if tf in requested_set]
|
||||||
timeframes = ordered[:4] if ordered else preferred_layout
|
for tf in requested:
|
||||||
|
if tf not in ordered:
|
||||||
|
ordered.append(tf)
|
||||||
|
timeframes = ordered[:4] if ordered else preferred_layout
|
||||||
|
|
||||||
ensure_markets_loaded()
|
ensure_markets_loaded()
|
||||||
panels = []
|
panels = []
|
||||||
@@ -824,6 +840,7 @@ def generate_multi_timeframe_chart_png(
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
end_ts_ms = None
|
end_ts_ms = None
|
||||||
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
|
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
|
||||||
|
price_levels = price_levels_from_marker_payload(marker_payload)
|
||||||
for tf in timeframes:
|
for tf in timeframes:
|
||||||
try:
|
try:
|
||||||
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
|
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
|
||||||
@@ -833,7 +850,6 @@ def generate_multi_timeframe_chart_png(
|
|||||||
ohlcv = []
|
ohlcv = []
|
||||||
rows = _ohlcv_to_rows(ohlcv)[-limit:]
|
rows = _ohlcv_to_rows(ohlcv)[-limit:]
|
||||||
title = f"{title_prefix} | {tf} x{len(rows)}"
|
title = f"{title_prefix} | {tf} x{len(rows)}"
|
||||||
points = []
|
|
||||||
tf_key = str(tf).strip().lower()
|
tf_key = str(tf).strip().lower()
|
||||||
if marker_payload:
|
if marker_payload:
|
||||||
if marker_timeframes:
|
if marker_timeframes:
|
||||||
@@ -842,54 +858,29 @@ def generate_multi_timeframe_chart_png(
|
|||||||
marker_tfs = default_marker_tfs
|
marker_tfs = default_marker_tfs
|
||||||
else:
|
else:
|
||||||
marker_tfs = set()
|
marker_tfs = set()
|
||||||
if marker_payload and tf_key in marker_tfs:
|
points = (
|
||||||
entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price"))
|
marker_points_for_timeframe(rows, marker_payload)
|
||||||
exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price"))
|
if marker_payload and tf_key in marker_tfs
|
||||||
if entry_idx is not None and entry_price is not None:
|
else []
|
||||||
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"})
|
|
||||||
panels.append(
|
panels.append(
|
||||||
_render_candles_subplot(
|
render_candles_subplot(
|
||||||
rows,
|
rows,
|
||||||
title,
|
title,
|
||||||
width=cell_w,
|
width=cell_w,
|
||||||
height=cell_h,
|
height=cell_h,
|
||||||
bg_rgb=(255, 255, 255),
|
bg_rgb=(255, 255, 255),
|
||||||
marker_points=points,
|
marker_points=points,
|
||||||
|
price_levels=price_levels,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not panels:
|
if not panels:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
gap = 10
|
out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10)
|
||||||
cols = 2
|
if out is None:
|
||||||
rows_n = int(math.ceil(len(panels) / cols))
|
return None
|
||||||
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)
|
|
||||||
|
|
||||||
target_dir = out_dir or ORDER_CHART_DIR
|
target_dir = out_dir or ORDER_CHART_DIR
|
||||||
os.makedirs(target_dir, exist_ok=True)
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
@@ -5842,6 +5833,10 @@ def render_main_page(page="trade"):
|
|||||||
funds_fmt=format_funds_u,
|
funds_fmt=format_funds_u,
|
||||||
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
||||||
entry_reason_other_value=ENTRY_REASON_OTHER,
|
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,
|
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||||
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
|
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
|
||||||
@@ -7352,32 +7347,36 @@ def add_journal():
|
|||||||
symbol_guess = normalize_symbol_input(coin) or coin
|
symbol_guess = normalize_symbol_input(coin) or coin
|
||||||
exchange_symbol = normalize_exchange_symbol(symbol_guess)
|
exchange_symbol = normalize_exchange_symbol(symbol_guess)
|
||||||
title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
|
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 = {
|
marker_payload = {
|
||||||
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
|
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
|
||||||
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
|
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
|
||||||
"entry_price": d.get("entry_price_hint"),
|
"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:
|
try:
|
||||||
chart_fname = f"journal_{entry_id}.png"
|
chart_fname = f"journal_{entry_id}.png"
|
||||||
saved = generate_multi_timeframe_chart_png(
|
saved = generate_multi_timeframe_chart_png(
|
||||||
exchange_symbol,
|
exchange_symbol,
|
||||||
title_prefix,
|
title_prefix,
|
||||||
timeframes=ORDER_CHART_TFS,
|
timeframes=journal_tfs,
|
||||||
limit=ORDER_CHART_LIMIT,
|
limit=journal_limit,
|
||||||
out_dir=app.config["UPLOAD_FOLDER"],
|
out_dir=app.config["UPLOAD_FOLDER"],
|
||||||
filename=chart_fname,
|
filename=chart_fname,
|
||||||
filename_prefix="journal",
|
filename_prefix="journal",
|
||||||
marker_payload=marker_payload,
|
marker_payload=marker_payload,
|
||||||
marker_timeframes=(
|
marker_timeframes={x.strip().lower() for x in journal_tfs},
|
||||||
{x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()}
|
layout="vertical",
|
||||||
if ORDER_CHART_TFS
|
|
||||||
else {"5m", "15m", "1h", "4h"}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if saved:
|
if saved:
|
||||||
image_filename = 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:
|
if uploaded_tmp:
|
||||||
try:
|
try:
|
||||||
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
|
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
|
||||||
|
|||||||
@@ -657,6 +657,7 @@
|
|||||||
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
|
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
|
||||||
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
|
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
|
||||||
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
||||||
|
<input type="hidden" name="exit_price_hint" id="exit-price-hint">
|
||||||
<input type="hidden" name="direction_hint" id="direction-hint">
|
<input type="hidden" name="direction_hint" id="direction-hint">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<input type="datetime-local" name="open_datetime" required>
|
<input type="datetime-local" name="open_datetime" required>
|
||||||
@@ -688,12 +689,31 @@
|
|||||||
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
||||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
||||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||||
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
||||||
保存时自动生成多周期K线图(4h/1h/15m/5m 各100)并作为截图
|
保存时自动生成 K 线图并作为截图
|
||||||
</label>
|
</label>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||||
|
<select name="journal_chart_tf1" style="min-width:72px">
|
||||||
|
{% for tf in journal_chart_tf_choices %}
|
||||||
|
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">周期2</label>
|
||||||
|
<select name="journal_chart_tf2" style="min-width:72px">
|
||||||
|
{% for tf in journal_chart_tf_choices %}
|
||||||
|
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">K线数</label>
|
||||||
|
<select name="journal_chart_limit" style="min-width:72px">
|
||||||
|
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
|
||||||
|
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sub" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;以平仓时间为锚点向前取 K 线;标注开仓、平仓与止损位</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
<div class="form-row" style="margin-top:8px">
|
||||||
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+52
-53
@@ -46,6 +46,18 @@ from fib_key_monitor_lib import (
|
|||||||
key_signal_type_for_trade_record,
|
key_signal_type_for_trade_record,
|
||||||
stored_key_signal_type,
|
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 (
|
from key_sl_tp_lib import (
|
||||||
breakeven_enabled_from_row,
|
breakeven_enabled_from_row,
|
||||||
normalize_sl_tp_mode,
|
normalize_sl_tp_mode,
|
||||||
@@ -793,20 +805,24 @@ def generate_multi_timeframe_chart_png(
|
|||||||
filename_prefix="chart",
|
filename_prefix="chart",
|
||||||
marker_payload=None,
|
marker_payload=None,
|
||||||
marker_timeframes=None,
|
marker_timeframes=None,
|
||||||
|
layout="grid",
|
||||||
):
|
):
|
||||||
if not ORDER_CHART_ENABLED:
|
if not ORDER_CHART_ENABLED:
|
||||||
return None
|
return None
|
||||||
if not Image:
|
if not Image:
|
||||||
return None
|
return None
|
||||||
requested = timeframes or ORDER_CHART_TFS
|
requested = list(timeframes or ORDER_CHART_TFS)
|
||||||
limit = limit or ORDER_CHART_LIMIT
|
limit = limit or ORDER_CHART_LIMIT
|
||||||
preferred_layout = ["5m", "15m", "1h", "4h"]
|
if layout == "vertical":
|
||||||
requested_set = set(requested or [])
|
timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
|
||||||
ordered = [tf for tf in preferred_layout if tf in requested_set]
|
else:
|
||||||
for tf in requested:
|
preferred_layout = ["5m", "15m", "1h", "4h"]
|
||||||
if tf not in ordered:
|
requested_set = set(requested or [])
|
||||||
ordered.append(tf)
|
ordered = [tf for tf in preferred_layout if tf in requested_set]
|
||||||
timeframes = ordered[:4] if ordered else preferred_layout
|
for tf in requested:
|
||||||
|
if tf not in ordered:
|
||||||
|
ordered.append(tf)
|
||||||
|
timeframes = ordered[:4] if ordered else preferred_layout
|
||||||
|
|
||||||
ensure_markets_loaded()
|
ensure_markets_loaded()
|
||||||
panels = []
|
panels = []
|
||||||
@@ -818,6 +834,7 @@ def generate_multi_timeframe_chart_png(
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
end_ts_ms = None
|
end_ts_ms = None
|
||||||
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
|
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
|
||||||
|
price_levels = price_levels_from_marker_payload(marker_payload)
|
||||||
for tf in timeframes:
|
for tf in timeframes:
|
||||||
try:
|
try:
|
||||||
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
|
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
|
||||||
@@ -827,7 +844,6 @@ def generate_multi_timeframe_chart_png(
|
|||||||
ohlcv = []
|
ohlcv = []
|
||||||
rows = _ohlcv_to_rows(ohlcv)[-limit:]
|
rows = _ohlcv_to_rows(ohlcv)[-limit:]
|
||||||
title = f"{title_prefix} | {tf} x{len(rows)}"
|
title = f"{title_prefix} | {tf} x{len(rows)}"
|
||||||
points = []
|
|
||||||
tf_key = str(tf).strip().lower()
|
tf_key = str(tf).strip().lower()
|
||||||
if marker_payload:
|
if marker_payload:
|
||||||
if marker_timeframes:
|
if marker_timeframes:
|
||||||
@@ -836,54 +852,29 @@ def generate_multi_timeframe_chart_png(
|
|||||||
marker_tfs = default_marker_tfs
|
marker_tfs = default_marker_tfs
|
||||||
else:
|
else:
|
||||||
marker_tfs = set()
|
marker_tfs = set()
|
||||||
if marker_payload and tf_key in marker_tfs:
|
points = (
|
||||||
entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price"))
|
marker_points_for_timeframe(rows, marker_payload)
|
||||||
exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price"))
|
if marker_payload and tf_key in marker_tfs
|
||||||
if entry_idx is not None and entry_price is not None:
|
else []
|
||||||
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"})
|
|
||||||
panels.append(
|
panels.append(
|
||||||
_render_candles_subplot(
|
render_candles_subplot(
|
||||||
rows,
|
rows,
|
||||||
title,
|
title,
|
||||||
width=cell_w,
|
width=cell_w,
|
||||||
height=cell_h,
|
height=cell_h,
|
||||||
bg_rgb=(255, 255, 255),
|
bg_rgb=(255, 255, 255),
|
||||||
marker_points=points,
|
marker_points=points,
|
||||||
|
price_levels=price_levels,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not panels:
|
if not panels:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
gap = 10
|
out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10)
|
||||||
cols = 2
|
if out is None:
|
||||||
rows_n = int(math.ceil(len(panels) / cols))
|
return None
|
||||||
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)
|
|
||||||
|
|
||||||
target_dir = out_dir or ORDER_CHART_DIR
|
target_dir = out_dir or ORDER_CHART_DIR
|
||||||
os.makedirs(target_dir, exist_ok=True)
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
@@ -5849,6 +5840,10 @@ def render_main_page(page="trade"):
|
|||||||
signed_usdt_fmt=format_signed_usdt,
|
signed_usdt_fmt=format_signed_usdt,
|
||||||
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
||||||
entry_reason_other_value=ENTRY_REASON_OTHER,
|
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,
|
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||||
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
|
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
|
||||||
@@ -7432,32 +7427,36 @@ def add_journal():
|
|||||||
symbol_guess = normalize_symbol_input(coin) or coin
|
symbol_guess = normalize_symbol_input(coin) or coin
|
||||||
exchange_symbol = normalize_exchange_symbol(symbol_guess)
|
exchange_symbol = normalize_exchange_symbol(symbol_guess)
|
||||||
title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
|
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 = {
|
marker_payload = {
|
||||||
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
|
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
|
||||||
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
|
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
|
||||||
"entry_price": d.get("entry_price_hint"),
|
"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:
|
try:
|
||||||
chart_fname = f"journal_{entry_id}.png"
|
chart_fname = f"journal_{entry_id}.png"
|
||||||
saved = generate_multi_timeframe_chart_png(
|
saved = generate_multi_timeframe_chart_png(
|
||||||
exchange_symbol,
|
exchange_symbol,
|
||||||
title_prefix,
|
title_prefix,
|
||||||
timeframes=ORDER_CHART_TFS,
|
timeframes=journal_tfs,
|
||||||
limit=ORDER_CHART_LIMIT,
|
limit=journal_limit,
|
||||||
out_dir=app.config["UPLOAD_FOLDER"],
|
out_dir=app.config["UPLOAD_FOLDER"],
|
||||||
filename=chart_fname,
|
filename=chart_fname,
|
||||||
filename_prefix="journal",
|
filename_prefix="journal",
|
||||||
marker_payload=marker_payload,
|
marker_payload=marker_payload,
|
||||||
marker_timeframes=(
|
marker_timeframes={x.strip().lower() for x in journal_tfs},
|
||||||
{x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()}
|
layout="vertical",
|
||||||
if ORDER_CHART_TFS
|
|
||||||
else {"5m", "15m", "1h", "4h"}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if saved:
|
if saved:
|
||||||
image_filename = 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:
|
if uploaded_tmp:
|
||||||
try:
|
try:
|
||||||
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
|
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
|
||||||
|
|||||||
@@ -657,6 +657,7 @@
|
|||||||
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
|
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
|
||||||
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
|
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
|
||||||
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
||||||
|
<input type="hidden" name="exit_price_hint" id="exit-price-hint">
|
||||||
<input type="hidden" name="direction_hint" id="direction-hint">
|
<input type="hidden" name="direction_hint" id="direction-hint">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<input type="datetime-local" name="open_datetime" required>
|
<input type="datetime-local" name="open_datetime" required>
|
||||||
@@ -688,12 +689,31 @@
|
|||||||
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
||||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
||||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||||
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
||||||
保存时自动生成多周期K线图(4h/1h/15m/5m 各100)并作为截图
|
保存时自动生成 K 线图并作为截图
|
||||||
</label>
|
</label>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||||
|
<select name="journal_chart_tf1" style="min-width:72px">
|
||||||
|
{% for tf in journal_chart_tf_choices %}
|
||||||
|
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">周期2</label>
|
||||||
|
<select name="journal_chart_tf2" style="min-width:72px">
|
||||||
|
{% for tf in journal_chart_tf_choices %}
|
||||||
|
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">K线数</label>
|
||||||
|
<select name="journal_chart_limit" style="min-width:72px">
|
||||||
|
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
|
||||||
|
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sub" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;以平仓时间为锚点向前取 K 线;标注开仓、平仓与止损位</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
<div class="form-row" style="margin-top:8px">
|
||||||
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ import sys
|
|||||||
if _REPO_ROOT not in sys.path:
|
if _REPO_ROOT not in sys.path:
|
||||||
sys.path.insert(0, _REPO_ROOT)
|
sys.path.insert(0, _REPO_ROOT)
|
||||||
from ai_client import ai_generate, ai_review, ai_short_advice
|
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 hub_auth import request_allowed as hub_request_allowed
|
||||||
from history_window_lib import (
|
from history_window_lib import (
|
||||||
PRESET_CUSTOM,
|
PRESET_CUSTOM,
|
||||||
@@ -770,20 +782,24 @@ def generate_multi_timeframe_chart_png(
|
|||||||
filename_prefix="chart",
|
filename_prefix="chart",
|
||||||
marker_payload=None,
|
marker_payload=None,
|
||||||
marker_timeframes=None,
|
marker_timeframes=None,
|
||||||
|
layout="grid",
|
||||||
):
|
):
|
||||||
if not ORDER_CHART_ENABLED:
|
if not ORDER_CHART_ENABLED:
|
||||||
return None
|
return None
|
||||||
if not Image:
|
if not Image:
|
||||||
return None
|
return None
|
||||||
requested = timeframes or ORDER_CHART_TFS
|
requested = list(timeframes or ORDER_CHART_TFS)
|
||||||
limit = limit or ORDER_CHART_LIMIT
|
limit = limit or ORDER_CHART_LIMIT
|
||||||
preferred_layout = ["5m", "15m", "1h", "4h"]
|
if layout == "vertical":
|
||||||
requested_set = set(requested or [])
|
timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
|
||||||
ordered = [tf for tf in preferred_layout if tf in requested_set]
|
else:
|
||||||
for tf in requested:
|
preferred_layout = ["5m", "15m", "1h", "4h"]
|
||||||
if tf not in ordered:
|
requested_set = set(requested or [])
|
||||||
ordered.append(tf)
|
ordered = [tf for tf in preferred_layout if tf in requested_set]
|
||||||
timeframes = ordered[:4] if ordered else preferred_layout
|
for tf in requested:
|
||||||
|
if tf not in ordered:
|
||||||
|
ordered.append(tf)
|
||||||
|
timeframes = ordered[:4] if ordered else preferred_layout
|
||||||
|
|
||||||
ensure_markets_loaded()
|
ensure_markets_loaded()
|
||||||
panels = []
|
panels = []
|
||||||
@@ -795,6 +811,7 @@ def generate_multi_timeframe_chart_png(
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
end_ts_ms = None
|
end_ts_ms = None
|
||||||
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
|
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
|
||||||
|
price_levels = price_levels_from_marker_payload(marker_payload)
|
||||||
for tf in timeframes:
|
for tf in timeframes:
|
||||||
try:
|
try:
|
||||||
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
|
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
|
||||||
@@ -804,7 +821,6 @@ def generate_multi_timeframe_chart_png(
|
|||||||
ohlcv = []
|
ohlcv = []
|
||||||
rows = _ohlcv_to_rows(ohlcv)[-limit:]
|
rows = _ohlcv_to_rows(ohlcv)[-limit:]
|
||||||
title = f"{title_prefix} | {tf} x{len(rows)}"
|
title = f"{title_prefix} | {tf} x{len(rows)}"
|
||||||
points = []
|
|
||||||
tf_key = str(tf).strip().lower()
|
tf_key = str(tf).strip().lower()
|
||||||
if marker_payload:
|
if marker_payload:
|
||||||
if marker_timeframes:
|
if marker_timeframes:
|
||||||
@@ -813,54 +829,29 @@ def generate_multi_timeframe_chart_png(
|
|||||||
marker_tfs = default_marker_tfs
|
marker_tfs = default_marker_tfs
|
||||||
else:
|
else:
|
||||||
marker_tfs = set()
|
marker_tfs = set()
|
||||||
if marker_payload and tf_key in marker_tfs:
|
points = (
|
||||||
entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price"))
|
marker_points_for_timeframe(rows, marker_payload)
|
||||||
exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price"))
|
if marker_payload and tf_key in marker_tfs
|
||||||
if entry_idx is not None and entry_price is not None:
|
else []
|
||||||
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"})
|
|
||||||
panels.append(
|
panels.append(
|
||||||
_render_candles_subplot(
|
render_candles_subplot(
|
||||||
rows,
|
rows,
|
||||||
title,
|
title,
|
||||||
width=cell_w,
|
width=cell_w,
|
||||||
height=cell_h,
|
height=cell_h,
|
||||||
bg_rgb=(255, 255, 255),
|
bg_rgb=(255, 255, 255),
|
||||||
marker_points=points,
|
marker_points=points,
|
||||||
|
price_levels=price_levels,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not panels:
|
if not panels:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
gap = 10
|
out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10)
|
||||||
cols = 2
|
if out is None:
|
||||||
rows_n = int(math.ceil(len(panels) / cols))
|
return None
|
||||||
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)
|
|
||||||
|
|
||||||
target_dir = out_dir or ORDER_CHART_DIR
|
target_dir = out_dir or ORDER_CHART_DIR
|
||||||
os.makedirs(target_dir, exist_ok=True)
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
@@ -5323,6 +5314,10 @@ def render_main_page(page="trade"):
|
|||||||
money_fmt=format_money_usdt,
|
money_fmt=format_money_usdt,
|
||||||
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
||||||
entry_reason_other_value=ENTRY_REASON_OTHER,
|
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,
|
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||||||
**strategy_extra,
|
**strategy_extra,
|
||||||
)
|
)
|
||||||
@@ -6839,32 +6834,36 @@ def add_journal():
|
|||||||
symbol_guess = normalize_symbol_input(coin) or coin
|
symbol_guess = normalize_symbol_input(coin) or coin
|
||||||
exchange_symbol = normalize_exchange_symbol(symbol_guess)
|
exchange_symbol = normalize_exchange_symbol(symbol_guess)
|
||||||
title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
|
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 = {
|
marker_payload = {
|
||||||
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
|
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
|
||||||
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
|
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
|
||||||
"entry_price": d.get("entry_price_hint"),
|
"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:
|
try:
|
||||||
chart_fname = f"journal_{entry_id}.png"
|
chart_fname = f"journal_{entry_id}.png"
|
||||||
saved = generate_multi_timeframe_chart_png(
|
saved = generate_multi_timeframe_chart_png(
|
||||||
exchange_symbol,
|
exchange_symbol,
|
||||||
title_prefix,
|
title_prefix,
|
||||||
timeframes=ORDER_CHART_TFS,
|
timeframes=journal_tfs,
|
||||||
limit=ORDER_CHART_LIMIT,
|
limit=journal_limit,
|
||||||
out_dir=app.config["UPLOAD_FOLDER"],
|
out_dir=app.config["UPLOAD_FOLDER"],
|
||||||
filename=chart_fname,
|
filename=chart_fname,
|
||||||
filename_prefix="journal",
|
filename_prefix="journal",
|
||||||
marker_payload=marker_payload,
|
marker_payload=marker_payload,
|
||||||
marker_timeframes=(
|
marker_timeframes={x.strip().lower() for x in journal_tfs},
|
||||||
{x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()}
|
layout="vertical",
|
||||||
if ORDER_CHART_TFS
|
|
||||||
else {"5m", "15m", "1h", "4h"}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if saved:
|
if saved:
|
||||||
image_filename = 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:
|
if uploaded_tmp:
|
||||||
try:
|
try:
|
||||||
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
|
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
|
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
|
||||||
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
|
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
|
||||||
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
||||||
|
<input type="hidden" name="exit_price_hint" id="exit-price-hint">
|
||||||
<input type="hidden" name="direction_hint" id="direction-hint">
|
<input type="hidden" name="direction_hint" id="direction-hint">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<input type="datetime-local" name="open_datetime" required>
|
<input type="datetime-local" name="open_datetime" required>
|
||||||
@@ -514,12 +515,31 @@
|
|||||||
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
||||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
||||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||||
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
||||||
保存时自动生成多周期K线图(4h/1h/15m/5m 各100)并作为截图
|
保存时自动生成 K 线图并作为截图
|
||||||
</label>
|
</label>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||||
|
<select name="journal_chart_tf1" style="min-width:72px">
|
||||||
|
{% for tf in journal_chart_tf_choices %}
|
||||||
|
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">周期2</label>
|
||||||
|
<select name="journal_chart_tf2" style="min-width:72px">
|
||||||
|
{% for tf in journal_chart_tf_choices %}
|
||||||
|
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">K线数</label>
|
||||||
|
<select name="journal_chart_limit" style="min-width:72px">
|
||||||
|
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
|
||||||
|
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sub" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;以平仓时间为锚点向前取 K 线;标注开仓、平仓与止损位</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
<div class="form-row" style="margin-top:8px">
|
||||||
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+52
-53
@@ -46,6 +46,18 @@ from fib_key_monitor_lib import (
|
|||||||
stored_key_signal_type,
|
stored_key_signal_type,
|
||||||
)
|
)
|
||||||
from okx_orders_lib import fetch_okx_all_open_orders
|
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 (
|
from key_sl_tp_lib import (
|
||||||
breakeven_enabled_from_row,
|
breakeven_enabled_from_row,
|
||||||
normalize_sl_tp_mode,
|
normalize_sl_tp_mode,
|
||||||
@@ -763,20 +775,24 @@ def generate_multi_timeframe_chart_png(
|
|||||||
filename_prefix="chart",
|
filename_prefix="chart",
|
||||||
marker_payload=None,
|
marker_payload=None,
|
||||||
marker_timeframes=None,
|
marker_timeframes=None,
|
||||||
|
layout="grid",
|
||||||
):
|
):
|
||||||
if not ORDER_CHART_ENABLED:
|
if not ORDER_CHART_ENABLED:
|
||||||
return None
|
return None
|
||||||
if not Image:
|
if not Image:
|
||||||
return None
|
return None
|
||||||
requested = timeframes or ORDER_CHART_TFS
|
requested = list(timeframes or ORDER_CHART_TFS)
|
||||||
limit = limit or ORDER_CHART_LIMIT
|
limit = limit or ORDER_CHART_LIMIT
|
||||||
preferred_layout = ["5m", "15m", "1h", "4h"]
|
if layout == "vertical":
|
||||||
requested_set = set(requested or [])
|
timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
|
||||||
ordered = [tf for tf in preferred_layout if tf in requested_set]
|
else:
|
||||||
for tf in requested:
|
preferred_layout = ["5m", "15m", "1h", "4h"]
|
||||||
if tf not in ordered:
|
requested_set = set(requested or [])
|
||||||
ordered.append(tf)
|
ordered = [tf for tf in preferred_layout if tf in requested_set]
|
||||||
timeframes = ordered[:4] if ordered else preferred_layout
|
for tf in requested:
|
||||||
|
if tf not in ordered:
|
||||||
|
ordered.append(tf)
|
||||||
|
timeframes = ordered[:4] if ordered else preferred_layout
|
||||||
|
|
||||||
ensure_markets_loaded()
|
ensure_markets_loaded()
|
||||||
panels = []
|
panels = []
|
||||||
@@ -788,6 +804,7 @@ def generate_multi_timeframe_chart_png(
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
end_ts_ms = None
|
end_ts_ms = None
|
||||||
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
|
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
|
||||||
|
price_levels = price_levels_from_marker_payload(marker_payload)
|
||||||
for tf in timeframes:
|
for tf in timeframes:
|
||||||
try:
|
try:
|
||||||
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
|
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
|
||||||
@@ -797,7 +814,6 @@ def generate_multi_timeframe_chart_png(
|
|||||||
ohlcv = []
|
ohlcv = []
|
||||||
rows = _ohlcv_to_rows(ohlcv)[-limit:]
|
rows = _ohlcv_to_rows(ohlcv)[-limit:]
|
||||||
title = f"{title_prefix} | {tf} x{len(rows)}"
|
title = f"{title_prefix} | {tf} x{len(rows)}"
|
||||||
points = []
|
|
||||||
tf_key = str(tf).strip().lower()
|
tf_key = str(tf).strip().lower()
|
||||||
if marker_payload:
|
if marker_payload:
|
||||||
if marker_timeframes:
|
if marker_timeframes:
|
||||||
@@ -806,54 +822,29 @@ def generate_multi_timeframe_chart_png(
|
|||||||
marker_tfs = default_marker_tfs
|
marker_tfs = default_marker_tfs
|
||||||
else:
|
else:
|
||||||
marker_tfs = set()
|
marker_tfs = set()
|
||||||
if marker_payload and tf_key in marker_tfs:
|
points = (
|
||||||
entry_idx, entry_price = _pick_marker_point(rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price"))
|
marker_points_for_timeframe(rows, marker_payload)
|
||||||
exit_idx, exit_price = _pick_marker_point(rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price"))
|
if marker_payload and tf_key in marker_tfs
|
||||||
if entry_idx is not None and entry_price is not None:
|
else []
|
||||||
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"})
|
|
||||||
panels.append(
|
panels.append(
|
||||||
_render_candles_subplot(
|
render_candles_subplot(
|
||||||
rows,
|
rows,
|
||||||
title,
|
title,
|
||||||
width=cell_w,
|
width=cell_w,
|
||||||
height=cell_h,
|
height=cell_h,
|
||||||
bg_rgb=(255, 255, 255),
|
bg_rgb=(255, 255, 255),
|
||||||
marker_points=points,
|
marker_points=points,
|
||||||
|
price_levels=price_levels,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not panels:
|
if not panels:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
gap = 10
|
out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10)
|
||||||
cols = 2
|
if out is None:
|
||||||
rows_n = int(math.ceil(len(panels) / cols))
|
return None
|
||||||
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)
|
|
||||||
|
|
||||||
target_dir = out_dir or ORDER_CHART_DIR
|
target_dir = out_dir or ORDER_CHART_DIR
|
||||||
os.makedirs(target_dir, exist_ok=True)
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
@@ -5208,6 +5199,10 @@ def render_main_page(page="trade"):
|
|||||||
price_fmt=format_price_for_symbol,
|
price_fmt=format_price_for_symbol,
|
||||||
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
||||||
entry_reason_other_value=ENTRY_REASON_OTHER,
|
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,
|
key_gate_rule_text=key_gate_rule_text,
|
||||||
funds_fmt=format_funds_u,
|
funds_fmt=format_funds_u,
|
||||||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||||||
@@ -6747,32 +6742,36 @@ def add_journal():
|
|||||||
symbol_guess = normalize_symbol_input(coin) or coin
|
symbol_guess = normalize_symbol_input(coin) or coin
|
||||||
exchange_symbol = normalize_okx_symbol(symbol_guess)
|
exchange_symbol = normalize_okx_symbol(symbol_guess)
|
||||||
title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
|
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 = {
|
marker_payload = {
|
||||||
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
|
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
|
||||||
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
|
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
|
||||||
"entry_price": d.get("entry_price_hint"),
|
"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:
|
try:
|
||||||
chart_fname = f"journal_{entry_id}.png"
|
chart_fname = f"journal_{entry_id}.png"
|
||||||
saved = generate_multi_timeframe_chart_png(
|
saved = generate_multi_timeframe_chart_png(
|
||||||
exchange_symbol,
|
exchange_symbol,
|
||||||
title_prefix,
|
title_prefix,
|
||||||
timeframes=ORDER_CHART_TFS,
|
timeframes=journal_tfs,
|
||||||
limit=ORDER_CHART_LIMIT,
|
limit=journal_limit,
|
||||||
out_dir=app.config["UPLOAD_FOLDER"],
|
out_dir=app.config["UPLOAD_FOLDER"],
|
||||||
filename=chart_fname,
|
filename=chart_fname,
|
||||||
filename_prefix="journal",
|
filename_prefix="journal",
|
||||||
marker_payload=marker_payload,
|
marker_payload=marker_payload,
|
||||||
marker_timeframes=(
|
marker_timeframes={x.strip().lower() for x in journal_tfs},
|
||||||
{x.strip().lower() for x in ORDER_CHART_TFS if x and str(x).strip()}
|
layout="vertical",
|
||||||
if ORDER_CHART_TFS
|
|
||||||
else {"5m", "15m", "1h", "4h"}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if saved:
|
if saved:
|
||||||
image_filename = 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:
|
if uploaded_tmp:
|
||||||
try:
|
try:
|
||||||
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
|
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
|
||||||
|
|||||||
@@ -666,6 +666,7 @@
|
|||||||
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
|
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
|
||||||
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
|
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
|
||||||
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
||||||
|
<input type="hidden" name="exit_price_hint" id="exit-price-hint">
|
||||||
<input type="hidden" name="direction_hint" id="direction-hint">
|
<input type="hidden" name="direction_hint" id="direction-hint">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<input type="datetime-local" name="open_datetime" required>
|
<input type="datetime-local" name="open_datetime" required>
|
||||||
@@ -697,12 +698,31 @@
|
|||||||
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
||||||
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
||||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||||
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
|
||||||
保存时自动生成多周期K线图(4h/1h/15m/5m 各100)并作为截图
|
保存时自动生成 K 线图并作为截图
|
||||||
</label>
|
</label>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||||
|
<select name="journal_chart_tf1" style="min-width:72px">
|
||||||
|
{% for tf in journal_chart_tf_choices %}
|
||||||
|
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">周期2</label>
|
||||||
|
<select name="journal_chart_tf2" style="min-width:72px">
|
||||||
|
{% for tf in journal_chart_tf_choices %}
|
||||||
|
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label style="font-size:.82rem;color:#9aa">K线数</label>
|
||||||
|
<select name="journal_chart_limit" style="min-width:72px">
|
||||||
|
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
|
||||||
|
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sub" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;以平仓时间为锚点向前取 K 线;标注开仓、平仓与止损位</div>
|
||||||
<div class="form-row" style="margin-top:8px">
|
<div class="form-row" style="margin-top:8px">
|
||||||
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user