期权复盘支持纯期权自动生成标的永续双周期K线,按时间标记进场出场。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""期权复盘自动 K 线:后台线程生成标的永续双周期拼图,写回 images_json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.options.options_review_images_lib import (
|
||||
images_json_dumps,
|
||||
parse_options_review_images_json,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPTIONS_REVIEW_AUTO_CHART_TF = "auto"
|
||||
|
||||
|
||||
def underlying_to_okx_perp_symbol(
|
||||
underlying: str,
|
||||
*,
|
||||
normalize_symbol_input_fn: Callable[[str], str],
|
||||
normalize_okx_symbol_fn: Callable[[str], str],
|
||||
) -> str:
|
||||
"""ETH / ETH-USD / ETH-USDT-SWAP → ETH/USDT:USDT."""
|
||||
raw = str(underlying or "").strip().upper()
|
||||
if not raw:
|
||||
return ""
|
||||
base = raw.split("-", 1)[0].split("/", 1)[0].strip()
|
||||
if not base:
|
||||
return ""
|
||||
guess = normalize_symbol_input_fn(base) or f"{base}/USDT"
|
||||
return str(normalize_okx_symbol_fn(guess) or "").strip()
|
||||
|
||||
|
||||
def merge_auto_chart_into_images(
|
||||
existing: Optional[list],
|
||||
filename: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""保留手动槽位,用 tf=auto 放置拼图(方案甲一张总图)."""
|
||||
fname = str(filename or "").strip()
|
||||
out: list[dict[str, str]] = []
|
||||
for item in existing or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tf = str(item.get("tf") or "").strip()
|
||||
file = str(item.get("file") or "").strip()
|
||||
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
|
||||
continue
|
||||
out.append({"tf": tf, "file": file})
|
||||
if fname:
|
||||
out.insert(0, {"tf": OPTIONS_REVIEW_AUTO_CHART_TF, "file": fname})
|
||||
return out
|
||||
|
||||
|
||||
def build_options_review_chart_filename(trade_id: int) -> str:
|
||||
return f"options_journal_chart_{int(trade_id)}.png"
|
||||
|
||||
|
||||
def schedule_options_review_exchange_chart(
|
||||
*,
|
||||
trade_id: int,
|
||||
exchange_symbol: str,
|
||||
title_prefix: str,
|
||||
journal_tfs: list[str],
|
||||
journal_limit: int,
|
||||
marker_payload: dict[str, Any],
|
||||
upload_folder: str,
|
||||
generate_chart_fn: Callable[..., str | None],
|
||||
get_db_fn: Callable[[], Any],
|
||||
now_str_fn: Callable[[], str],
|
||||
) -> None:
|
||||
"""保存后立即返回;线程内画图并合并进 options_review_entries.images_json."""
|
||||
tid = int(trade_id or 0)
|
||||
if tid <= 0 or not callable(generate_chart_fn) or not callable(get_db_fn):
|
||||
return
|
||||
symbol = str(exchange_symbol or "").strip()
|
||||
if not symbol:
|
||||
return
|
||||
tfs = [str(x).strip() for x in (journal_tfs or []) if str(x).strip()]
|
||||
if not tfs:
|
||||
return
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
chart_fname = build_options_review_chart_filename(tid)
|
||||
saved = generate_chart_fn(
|
||||
symbol,
|
||||
title_prefix,
|
||||
timeframes=tfs,
|
||||
limit=journal_limit,
|
||||
out_dir=upload_folder,
|
||||
filename=chart_fname,
|
||||
filename_prefix="options_journal_chart",
|
||||
marker_payload=marker_payload,
|
||||
marker_timeframes={x.lower() for x in tfs},
|
||||
layout="vertical",
|
||||
)
|
||||
if not saved:
|
||||
logger.warning("options review chart async empty trade_id=%s", tid)
|
||||
return
|
||||
conn = get_db_fn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
|
||||
(tid,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
logger.warning(
|
||||
"options review chart async missing entry trade_id=%s", tid
|
||||
)
|
||||
return
|
||||
images = parse_options_review_images_json(row["images_json"])
|
||||
merged = merge_auto_chart_into_images(images, saved)
|
||||
primary = merged[0]["file"] if merged else saved
|
||||
conn.execute(
|
||||
"""UPDATE options_review_entries
|
||||
SET images_json=?, image=?, updated_at=?
|
||||
WHERE trade_id=?""",
|
||||
(
|
||||
images_json_dumps(merged),
|
||||
primary,
|
||||
now_str_fn(),
|
||||
tid,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.exception("options review chart async failed trade_id=%s", tid)
|
||||
|
||||
threading.Thread(
|
||||
target=_run,
|
||||
name=f"options-review-chart-{tid}",
|
||||
daemon=True,
|
||||
).start()
|
||||
@@ -93,6 +93,141 @@ def _require_ex(cfg: dict[str, Any]):
|
||||
return ex, ""
|
||||
|
||||
|
||||
def _truthy(raw: Any) -> bool:
|
||||
return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
|
||||
|
||||
|
||||
def _prepare_review_entry_images(
|
||||
conn: Any,
|
||||
trade_id: int,
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图."""
|
||||
from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF
|
||||
from lib.options.options_review_images_lib import parse_options_review_images_json
|
||||
|
||||
images = data.get("images") if isinstance(data.get("images"), list) else []
|
||||
cleaned: list[dict[str, str]] = []
|
||||
for item in images:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tf = str(item.get("tf") or "").strip()
|
||||
file = str(item.get("file") or "").strip()
|
||||
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
|
||||
continue
|
||||
cleaned.append({"tf": tf, "file": file})
|
||||
|
||||
want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned
|
||||
if not want_chart:
|
||||
row = conn.execute(
|
||||
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
|
||||
(int(trade_id),),
|
||||
).fetchone()
|
||||
if row:
|
||||
for item in parse_options_review_images_json(row["images_json"]):
|
||||
if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF:
|
||||
cleaned.insert(0, dict(item))
|
||||
break
|
||||
|
||||
out = dict(data)
|
||||
out["images"] = cleaned
|
||||
return out
|
||||
|
||||
|
||||
def _maybe_schedule_options_review_chart(
|
||||
cfg: dict[str, Any],
|
||||
trade: dict[str, Any],
|
||||
data: dict[str, Any],
|
||||
) -> bool:
|
||||
"""纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场)."""
|
||||
if str(trade.get("source_type") or "").strip() != "option_spot":
|
||||
return False
|
||||
images = data.get("images") if isinstance(data.get("images"), list) else []
|
||||
has_manual = any(str((x or {}).get("file") or "").strip() for x in images)
|
||||
if has_manual or not _truthy(data.get("journal_exchange_chart")):
|
||||
return False
|
||||
|
||||
app_module = cfg.get("app_module")
|
||||
if app_module is None:
|
||||
return False
|
||||
if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)):
|
||||
return False
|
||||
generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None)
|
||||
if not callable(generate_fn):
|
||||
return False
|
||||
|
||||
from lib.instance.journal_chart_lib import (
|
||||
parse_journal_chart_anchor,
|
||||
parse_journal_chart_limit,
|
||||
parse_journal_chart_timeframes,
|
||||
)
|
||||
from lib.options.options_review_chart_async_lib import (
|
||||
schedule_options_review_exchange_chart,
|
||||
underlying_to_okx_perp_symbol,
|
||||
)
|
||||
from lib.options.options_review_images_lib import options_review_upload_dir
|
||||
from lib.options.options_review_lib import _now_str
|
||||
|
||||
fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2]
|
||||
journal_tfs = parse_journal_chart_timeframes(
|
||||
data.get("journal_chart_tf1"),
|
||||
data.get("journal_chart_tf2"),
|
||||
fallback_tfs or None,
|
||||
)
|
||||
journal_limit = parse_journal_chart_limit(
|
||||
data.get("journal_chart_limit"),
|
||||
int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100),
|
||||
)
|
||||
chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor"))
|
||||
|
||||
underlying = str(trade.get("underlying") or data.get("underlying") or "").strip()
|
||||
exchange_symbol = underlying_to_okx_perp_symbol(
|
||||
underlying,
|
||||
normalize_symbol_input_fn=app_module.normalize_symbol_input,
|
||||
normalize_okx_symbol_fn=app_module.normalize_okx_symbol,
|
||||
)
|
||||
if not exchange_symbol:
|
||||
return False
|
||||
|
||||
to_ms = getattr(app_module, "_local_input_datetime_to_ms", None)
|
||||
if not callable(to_ms):
|
||||
return False
|
||||
entry_ts = to_ms(trade.get("opened_at"))
|
||||
exit_ts = to_ms(trade.get("closed_at"))
|
||||
if entry_ts is None and exit_ts is None:
|
||||
return False
|
||||
|
||||
now_ms = None
|
||||
app_now = getattr(app_module, "app_now", None)
|
||||
if callable(app_now):
|
||||
try:
|
||||
now_ms = int(app_now().timestamp() * 1000)
|
||||
except Exception:
|
||||
now_ms = None
|
||||
|
||||
inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip()
|
||||
upload_folder = options_review_upload_dir(cfg["upload_folder"])
|
||||
schedule_options_review_exchange_chart(
|
||||
trade_id=int(trade["id"]),
|
||||
exchange_symbol=exchange_symbol,
|
||||
title_prefix=f"{underlying or 'OPT'} {inst} review",
|
||||
journal_tfs=journal_tfs,
|
||||
journal_limit=journal_limit,
|
||||
marker_payload={
|
||||
"entry_ts_ms": entry_ts,
|
||||
"exit_ts_ms": exit_ts,
|
||||
# 只按时间钉进/出场,不传价格与止损
|
||||
"chart_anchor": chart_anchor,
|
||||
"now_ts_ms": now_ms,
|
||||
},
|
||||
upload_folder=upload_folder,
|
||||
generate_chart_fn=generate_fn,
|
||||
get_db_fn=cfg["get_db"],
|
||||
now_str_fn=_now_str,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None:
|
||||
lr = cfg["login_required"]
|
||||
|
||||
@@ -216,9 +351,16 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
||||
return jsonify({"ok": False, "msg": "trade_id 无效"}), 400
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
data = _prepare_review_entry_images(conn, trade_id, data)
|
||||
out = save_review_entry(conn, trade_id, data)
|
||||
if out.get("ok"):
|
||||
conn.commit()
|
||||
chart_queued = _maybe_schedule_options_review_chart(
|
||||
cfg, out.get("trade") or {}, data
|
||||
)
|
||||
if chart_queued:
|
||||
out = dict(out)
|
||||
out["chart_queued"] = True
|
||||
return jsonify(out), (200 if out.get("ok") else 400)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -327,6 +327,37 @@
|
||||
</div>
|
||||
<p class="sub journal-upload-hint">可只传部分周期;选文件后即时上传</p>
|
||||
|
||||
<div class="form-row or-chart-options journal-chart-options hidden" id="or-chart-options" style="flex-wrap:wrap;align-items:center">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem">
|
||||
<input type="checkbox" id="or-exchange-chart" value="true">
|
||||
保存后后台生成 K 线图并作为截图(不阻塞保存)
|
||||
</label>
|
||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||
<select id="or-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 id="or-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 id="or-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>
|
||||
<label style="font-size:.82rem;color:#9aa">K线截止</label>
|
||||
<select id="or-chart-anchor" style="min-width:96px" title="K线窗口右端对齐的时间">
|
||||
<option value="close" {% if journal_chart_default_anchor == 'close' %}selected{% endif %}>平仓时间</option>
|
||||
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sub or-chart-hint hidden" id="or-chart-hint" style="font-size:.72rem;margin-top:2px;margin-bottom:0">纯期权:按标的永续画双周期上下图;只按时间标记进场/出场,不标止损;有手动截图时不自动生成</div>
|
||||
|
||||
<div class="or-mood-grid mood-grid">
|
||||
<label><input type="checkbox" class="or-mood" value="怕踏空">怕踏空</label>
|
||||
<label><input type="checkbox" class="or-mood" value="报复开仓">报复开仓</label>
|
||||
|
||||
Reference in New Issue
Block a user