diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 1c3b947..08fc3b8 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -29,6 +29,29 @@ except ImportError: ImageFont = None # type: ignore BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(BASE_DIR) +import sys + +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +from fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + calc_fib_plan, + entry_reason_from_key_signal, + fib_invalidate_by_mark, + fib_ratio_from_type, + is_fib_key_monitor_type, + key_signal_type_for_trade_record, + stored_key_signal_type, +) +from history_window_lib import ( + PRESET_CUSTOM, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST7D, + PRESET_UTC_TODAY, + resolve_window, + utc_window_to_bj_sql_strings, +) def load_env_file(path): @@ -138,6 +161,18 @@ RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) +ORDER_MONITOR_TYPE_MANUAL = "下单监控" +ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" +KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) +KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) +KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) +KEY_DAILY_VOLUME_RANK_MAX = int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30")) +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() in ( + "1", + "true", + "yes", + "on", +) DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate") AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") @@ -617,6 +652,40 @@ def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), return img +def _timeframe_period_ms(tf): + s = (tf or "").strip().lower() + if s.endswith("m"): + try: + return int(s[:-1]) * 60 * 1000 + except ValueError: + pass + if s.endswith("h"): + try: + return int(s[:-1]) * 3600 * 1000 + except ValueError: + pass + if s.endswith("d"): + try: + return int(s[:-1]) * 86400 * 1000 + except ValueError: + pass + return 300000 + + +def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): + lim = max(2, int(limit or ORDER_CHART_LIMIT)) + if not end_ts_ms: + return exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) + period = _timeframe_period_ms(timeframe) + since = int(end_ts_ms) - period * (lim + 5) + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 10) + rows = _ohlcv_to_rows(ohlcv) + filtered = [r for r in rows if int(r[0]) <= int(end_ts_ms)] + if len(filtered) >= lim: + return [[r[0], r[1], r[2], r[3], r[4]] for r in filtered[-lim:]] + return ohlcv[-lim:] if ohlcv else [] + + def generate_multi_timeframe_chart_png( exchange_symbol, title_prefix, @@ -645,9 +714,15 @@ def generate_multi_timeframe_chart_png( ensure_markets_loaded() panels = [] cell_w, cell_h = 980, 520 + end_ts_ms = None + if marker_payload: + try: + end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None + except (TypeError, ValueError): + end_ts_ms = None for tf in timeframes: try: - ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) + ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) except Exception: ohlcv = [] rows = _ohlcv_to_rows(ohlcv)[-limit:] @@ -738,6 +813,7 @@ def journal_coin_from_symbol(symbol): EARLY_EXIT_TRIGGERS = ( "", + "止盈", "保本止盈", "移动止盈", "手动平仓", @@ -752,11 +828,28 @@ ENTRY_REASON_OPTIONS = ( "趋势多头:小分歧低吸入场(左侧),确认条件:二次探底", "趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶", "波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20", + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", ) +STATS_SEGMENT_DEFS = ( + ("all", "全部交易", {"segment": "all"}), + ("manual", "下单监控", {"segment": "manual"}), + ("key_box", "关键位箱体突破", {"segment": "key_box"}), + ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), + ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), + ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), +) +ENTRY_REASON_OTHER = "__OTHER__" -def normalize_entry_reason(raw): + +def normalize_entry_reason(raw, custom_text=None): v = str(raw or "").strip() + if v == ENTRY_REASON_OTHER: + c = str(custom_text or "").strip() + return c[:2000] if c else "" return v if v in ENTRY_REASON_OPTIONS else "" @@ -1110,6 +1203,35 @@ def init_db(): try: c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '下单监控'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT") + except: pass + for ddl in ( + "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", + "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", + ): + try: + c.execute(ddl) + except Exception: + pass + try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass c.execute( """CREATE TABLE IF NOT EXISTS key_monitor_history @@ -1221,17 +1343,64 @@ def _calendar_month_bounds(local_dt): def _count_opens_between(conn, start_td, end_td): + return _count_opens_for_segment(conn, start_td, end_td, "all") + + +def _list_window_from_request(): + return resolve_window(request.args, default_preset=PRESET_UTC_TODAY) + + +def _pnl_row_matches_segment(row, segment_key): + try: + mt = (row["monitor_type"] or "").strip() + kst = (row["key_signal_type"] or "").strip() + except Exception: + return False + if segment_key == "all": + return True + if segment_key == "manual": + return mt == ORDER_MONITOR_TYPE_MANUAL and not kst + if segment_key == "key_box": + return kst == "箱体突破" + if segment_key == "key_conv": + return kst == "收敛突破" + if segment_key == "key_fib618": + return kst == "斐波回调0.618" + if segment_key == "key_fib786": + return kst == "斐波回调0.786" + return False + + +def _count_opens_for_segment(conn, start_td, end_td, segment_key): + if segment_key == "manual": + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " + "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", + (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), + ).fetchone()[0] + kst_map = { + "key_box": "箱体突破", + "key_conv": "收敛突破", + "key_fib618": "斐波回调0.618", + "key_fib786": "斐波回调0.786", + } + kst = kst_map.get(segment_key) + if kst: + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", + (start_td, end_td, kst), + ).fetchone()[0] return conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", (start_td, end_td), ).fetchone()[0] -def _load_completed_live_pnls(conn): - q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, - result, reviewed_result +def _load_completed_trade_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, + result, reviewed_result, monitor_type, key_signal_type FROM trade_records - WHERE monitor_type = '下单监控' ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" rows = conn.execute(q).fetchall() out = [] @@ -1245,7 +1414,7 @@ def _load_completed_live_pnls(conn): p = 0.0 t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) td = get_trading_day(t) if t else None - out.append((p, t, td)) + out.append((p, t, td, r)) return out @@ -1314,34 +1483,35 @@ def _compute_period_metrics(trades): def compute_stats_bundle(conn, trading_day, now_dt=None): - """日 / 周 / 月 统计:平仓按平仓时间所在交易日计入。""" + """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" now_dt = now_dt or app_now() - pnls = _load_completed_live_pnls(conn) + pnls = _load_completed_trade_pnls(conn) total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] w_start, w_end = _session_week_bounds(trading_day) m_start, m_end = _calendar_month_bounds(now_dt) - def in_week(tr): - _p, _t, td = tr - return td and w_start <= td <= w_end + def slice_metrics(seg_key): + seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] + day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] + week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] + month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] + dm = _compute_period_metrics(day_tr) + wm = _compute_period_metrics(week_tr) + mm = _compute_period_metrics(month_tr) + dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) + wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) + mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) + dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" + wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" + mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" + return dm, wm, mm - def in_month(tr): - _p, _t, td = tr - return td and m_start <= td <= m_end + segments = [] + for seg_key, seg_title, _meta in STATS_SEGMENT_DEFS: + dm, wm, mm = slice_metrics(seg_key) + segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) - day_trades = [tr for tr in pnls if tr[2] == trading_day] - week_trades = [tr for tr in pnls if in_week(tr)] - month_trades = [tr for tr in pnls if in_month(tr)] - - dm = _compute_period_metrics(day_trades) - wm = _compute_period_metrics(week_trades) - mm = _compute_period_metrics(month_trades) - dm["opens_count"] = _count_opens_between(conn, trading_day, trading_day) - wm["opens_count"] = _count_opens_between(conn, w_start, w_end) - mm["opens_count"] = _count_opens_between(conn, m_start, m_end) - dm["range_label"] = f"北京时间交易日 {trading_day}" - wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天窗口)" - mm["range_label"] = f"{m_start} ~ {m_end}(北京时间自然月)" + dm, wm, mm = slice_metrics("all") return { "trading_day": trading_day, @@ -1349,6 +1519,8 @@ def compute_stats_bundle(conn, trading_day, now_dt=None): "day": dm, "week": wm, "month": mm, + "segments": segments, + "stats_reset_hour": TRADING_DAY_RESET_HOUR, } @@ -1370,6 +1542,37 @@ def normalize_okx_symbol(symbol): return sym +def resolve_monitor_exchange_symbol(row): + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_okx_symbol(raw) if raw else "" + + +def round_price_to_exchange(exchange_symbol, price): + if price in (None, ""): + return None + try: + v = float(price) + except (TypeError, ValueError): + return None + if not exchange_symbol: + return v + try: + ensure_markets_loaded() + return float(exchange.price_to_precision(exchange_symbol, v)) + except Exception: + return v + + def normalize_symbol_input(symbol): sym = (symbol or "").strip().upper() if not sym: @@ -1473,7 +1676,11 @@ def to_effective_trade_dict(row): base_stop = item.get("initial_stop_loss") if item.get("initial_stop_loss") not in (None, "") else item.get("stop_loss") item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) - item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", base_stop) + open_stop = item.get("initial_stop_loss") + if open_stop in (None, ""): + open_stop = base_stop + item["display_open_stop_loss"] = open_stop + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) @@ -1659,19 +1866,24 @@ def insert_trade_record( closed_at=None, closed_at_ms=None, exchange_trade_id=None, + key_signal_type=None, + entry_reason=None, ): hold_minutes = calc_hold_minutes(hold_seconds) open_ts = opened_at or app_now_str() close_ts = closed_at or app_now_str() open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) + kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) + snap_sl = initial_stop_loss if initial_stop_loss not in (None, "") else stop_loss + er = (entry_reason or "").strip() or entry_reason_from_key_signal(kst) or "" conn.execute( - "INSERT INTO trade_records (symbol,monitor_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( - symbol, monitor_type, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, + symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, margin_capital, leverage, pnl_amount, hold_seconds, trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, - open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None ) ) @@ -1722,6 +1934,11 @@ def enrich_order_item(raw_item, current_capital): item.get("initial_stop_loss") or item.get("stop_loss"), item.get("take_profit"), ) + try: + be = item.get("breakeven_enabled") + item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 + except Exception: + item["breakeven_enabled"] = 1 return item @@ -2100,6 +2317,65 @@ def close_exchange_order(order_row): return exchange.create_order(exchange_symbol, "market", side, amount, None, params) +def cancel_okx_swap_open_orders(exchange_symbol): + ok, _ = ensure_okx_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + try: + exchange.cancel_all_orders(exchange_symbol) + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + oid = o.get("id") + if oid is None: + continue + try: + exchange.cancel_order(str(oid), exchange_symbol) + except Exception: + pass + except Exception: + pass + + +def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_profit): + ensure_markets_loaded() + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(amount))) + if amt <= 0: + raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") + params = build_okx_order_params(direction, reduce_only=True) + params["stopLoss"] = { + "triggerPrice": _okx_algo_trigger_price_str(exchange_symbol, stop_loss), + "type": "market", + } + params["takeProfit"] = { + "triggerPrice": _okx_algo_trigger_price_str(exchange_symbol, take_profit), + "type": "market", + } + exchange.create_order(exchange_symbol, "market", close_side, amt, None, params) + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + """先撤该合约挂单/条件单,再按新价重挂 TP/SL。""" + ok, reason = ensure_okx_live_ready() + if not ok: + raise RuntimeError(reason or "实盘未就绪") + ex_sym = resolve_monitor_exchange_symbol(order_row) + direction = order_row["direction"] + cancel_okx_swap_open_orders(ex_sym) + pos_amt = get_live_position_contracts(ex_sym, direction) + if pos_amt is None or float(pos_amt) <= 0: + try: + pos_amt = float(order_row["order_amount"] or 0) + except (TypeError, ValueError): + pos_amt = 0 + if float(pos_amt or 0) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + _okx_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) + + def extract_trade_price_from_order(order): if not order: return None @@ -2724,6 +3000,489 @@ def calc_price_diff_pct(current_price, target_price): return None, None + +def _coerce_float(*values): + for v in values: + if v is None: + continue + try: + f = float(v) + if f > 0: + return f + except (TypeError, ValueError): + continue + return None + + +def _sqlite_row_val(row, key, default=None): + try: + v = row[key] + return default if v is None else v + except (KeyError, IndexError, TypeError): + return default + + +def get_active_position_count(conn): + return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) + + +def get_key_sizing_capital_snapshot(conn, session_date): + row = conn.execute( + "SELECT key_sizing_capital_snapshot FROM trading_sessions WHERE session_date=?", + (session_date,), + ).fetchone() + if not row: + return None + try: + v = row["key_sizing_capital_snapshot"] + return float(v) if v is not None else None + except (TypeError, ValueError, KeyError): + return None + + +def set_key_sizing_capital_snapshot(conn, session_date, capital): + ensure_session(conn, session_date) + conn.execute( + "UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(float(capital), 4), session_date), + ) + conn.commit() + + +def resolve_capital_base_for_key_open(conn, trading_day, live_capital): + live = float(live_capital) + active = get_active_position_count(conn) + if active <= 0: + set_key_sizing_capital_snapshot(conn, trading_day, live) + return live + if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT: + snap = get_key_sizing_capital_snapshot(conn, trading_day) + if snap is not None and snap > 0: + return snap + return live + + +def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): + n = int(row["notification_count"] or 0) + 1 + insert_key_monitor_history(conn, row, n, last_msg, close_reason) + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + + +def get_symbol_mark_price(symbol): + """斐波失效判定用标记价。""" + ex_sym = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + ticker = exchange.fetch_ticker(ex_sym) + m = _coerce_float(ticker.get("mark"), ticker.get("last")) + if m is None: + info = ticker.get("info") or {} + m = _coerce_float(info.get("markPx"), info.get("last")) + if m is not None: + return float(m) + except Exception: + pass + p = get_price(symbol) + return float(p) if p is not None else None + + +def cancel_fib_limit_order(exchange_symbol, order_id): + if not order_id: + return False + ok_live, _ = ensure_okx_live_ready() + if not ok_live: + return False + ensure_markets_loaded() + oid = str(order_id) + try: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + return False + + +def fib_limit_order_status(exchange_symbol, order_id): + if not order_id: + return "missing" + ensure_markets_loaded() + oid = str(order_id) + try: + o = exchange.fetch_order(oid, exchange_symbol) + st = (o.get("status") or "").lower() + if st in ("closed", "filled"): + filled = float(o.get("filled") or 0) + if filled > 0 or st == "filled": + return "filled" + if st in ("canceled", "cancelled", "expired", "rejected"): + return "canceled" + if st in ("open", "new", "partially_filled", "live"): + return "open" + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + return "open" + except Exception: + pass + return "unknown" + + +def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + price = round_price_to_exchange(exchange_symbol, float(limit_price)) + if price is None or price <= 0: + raise ValueError("挂单价无效") + params = build_okx_order_params(direction, reduce_only=False) + return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) + + +def _fib_key_exists_for_symbol(conn, symbol): + ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", + (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), + ).fetchone() + return row is not None + + +def _fib_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + ratio = fib_ratio_from_type(typ) + if ratio is None: + return None + return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) + + +def _cancel_fib_monitor_limit(row): + ex_sym = normalize_okx_symbol(row["symbol"]) + oid = _sqlite_row_val(row, "fib_limit_order_id") + if oid: + cancel_fib_limit_order(ex_sym, oid) + + +def _fib_has_live_position(exchange_symbol, direction): + live = get_live_position_contracts(exchange_symbol, direction) + return live is not None and float(live) > 0 + + +def _insert_order_monitor_from_fib_fill( + conn, + row, + trigger_price, + stop_loss, + take_profit, + amount, + leverage, + margin_capital, + notional_value, + position_ratio, + base_amount, + exchange_order_id, +): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + exchange_symbol = normalize_okx_symbol(symbol) + typ = (row["monitor_type"] or "").strip() + now = app_now() + trading_day = get_trading_day(now) + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + 1, + notional_value, + position_ratio, + base_amount, + amount, + exchange_order_id or "", + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(typ), + ), + ) + return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + + +def _finalize_fib_key_fill(conn, row): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + typ = (row["monitor_type"] or "").strip() + ex_sym = normalize_okx_symbol(symbol) + plan = _fib_plan_for_row(row) + if not plan: + _finalize_key_monitor_one_shot(conn, row, "斐波计划无效", "fib_plan_invalid") + return + entry_plan, sl_plan, tp_plan = plan + sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) + tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) + sl_adj = round_price_to_exchange(ex_sym, sl) + tp_adj = round_price_to_exchange(ex_sym, tp) + if sl_adj is not None: + sl = float(sl_adj) + if tp_adj is not None: + tp = float(tp_adj) + amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) + leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) + margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) + oid = _sqlite_row_val(row, "fib_limit_order_id") + entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) + trigger_price = entry_px + if oid: + try: + o = exchange.fetch_order(str(oid), ex_sym) + trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) + except Exception: + pass + tr_adj = round_price_to_exchange(ex_sym, trigger_price) + if tr_adj is not None: + trigger_price = float(tr_adj) + if amount <= 0: + live_amt = get_live_position_contracts(ex_sym, direction) + amount = float(live_amt or 0) + if amount <= 0: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后处理失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 无法取得持仓/下单数量,未挂 TP/SL\n" + ) + return + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后风控拒绝\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" + f"- 请手动处理仓位与挂单\n" + ) + return + tpsl_attached = False + try: + _okx_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) + tpsl_attached = True + except Exception as e: + send_wechat_msg( + f"# ❌ {symbol} 斐波成交后挂 TP/SL 失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_okx_error(e)}\n" + f"- 请手动补挂止盈止损\n" + ) + return + contract_size = get_contract_size(ex_sym) + base_amount = round(float(amount) * contract_size, 8) + notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 + session_row = ensure_session(conn, get_trading_day(app_now())) + capital_base = float(session_row["current_capital"] or 0) + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 + planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) + new_order_id = _insert_order_monitor_from_fib_fill( + conn, + row, + trigger_price, + sl, + tp, + amount, + leverage, + margin_capital, + notional_value, + position_ratio, + base_amount, + oid, + ) + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "-" + succ = ( + f"# ✅ {symbol} 斐波限价成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{new_order_id}**\n" + f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" + f"- 止损:{sl}|止盈:{format_price_for_symbol(symbol, tp)}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(succ) + _finalize_key_monitor_one_shot(conn, row, succ, "fib_filled") + + +def check_fib_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + typ = (r["monitor_type"] or "").strip() + if not is_fib_key_monitor_type(typ): + continue + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + ex_sym = normalize_okx_symbol(symbol) + up, low = float(r["upper"]), float(r["lower"]) + oid = _sqlite_row_val(r, "fib_limit_order_id") + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + status = fib_limit_order_status(ex_sym, oid) if oid else "missing" + if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): + _finalize_fib_key_fill(conn, r) + continue + if status == "open": + if fib_invalidate_by_mark(direction, mark, up, low): + _cancel_fib_monitor_limit(r) + msg = ( + f"# ⚠️ {symbol} 斐波监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + continue + if status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): + msg = ( + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + conn.commit() + conn.close() + + +def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px): + if _fib_key_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + ratio = fib_ratio_from_type(mt) + plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) + if not plan: + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" + entry, sl, tp = plan + ex_sym = normalize_okx_symbol(symbol) + entry = round_price_to_exchange(ex_sym, entry) + sl = round_price_to_exchange(ex_sym, sl) + tp = round_price_to_exchange(ex_sym, tp) + if entry is None or sl is None or tp is None: + return False, "斐波价位经交易所精度舍入后无效" + entry, sl, tp = float(entry), float(sl), float(tp) + planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" + ok, reason = precheck_risk(conn, symbol, direction_sel) + if not ok: + return False, reason + ok_live, reason_live = ensure_okx_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + available_usdt = get_available_trading_usdt() + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金" + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + ) + try: + amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) + oid = str(order_resp.get("id") or "") + if not oid: + return False, "交易所未返回限价单 ID" + except Exception as e: + return False, friendly_okx_error(e, available_usdt=available_usdt) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + oid, + entry, + sl, + tp, + float(amount), + margin_capital, + leverage, + ), + ) + return True, None + + def can_notify_key_monitor(row, now_dt): max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) if int(row["notification_count"] or 0) >= max_notify: @@ -2754,7 +3513,10 @@ def check_key_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() for r in rows: - sym, typ, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + typ = (typ_raw or "").strip() + if is_fib_key_monitor_type(typ): + continue direction = (r["direction"] or "long").lower() now_dt = app_now() if not can_notify_key_monitor(r, now_dt): @@ -2852,7 +3614,13 @@ def check_order_monitors(): trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) step_r = 1.0 if step_r <= 0 else step_r - if risk_amount > 0 and trigger_rr > 0: + breakeven_enabled = True + try: + if "breakeven_enabled" in r.keys(): + breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 + except Exception: + breakeven_enabled = True + if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) now_rr = now_pnl / risk_amount if now_rr >= trigger_rr: @@ -2873,14 +3641,36 @@ def check_order_monitors(): direction == "long" and new_sl > float(stop_loss) ) if should_move: - conn.execute( - "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", - (new_sl, new_sl, pid), - ) - stop_loss = new_sl - arm_txt = "保本止盈" if not breakeven_armed else "移动止盈" - send_wechat_msg( - build_wechat_breakeven_message( + ex_sym = resolve_monitor_exchange_symbol(r) + new_sl = round_price_to_exchange(ex_sym, new_sl) + tp_ex = float(take_profit or 0) + ok_live, _live_reason = ensure_okx_live_ready() + synced_ex = not ok_live + if ok_live and tp_ex > 0: + try: + replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) + synced_ex = True + except Exception as e: + print( + f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}", + flush=True, + ) + send_wechat_msg( + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_okx_error(e)}" + ) + elif ok_live: + print( + f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit", + flush=True, + ) + if synced_ex: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, pid), + ) + stop_loss = new_sl + arm_txt = "保本止盈" if not breakeven_armed else "移动止盈" + be_msg = build_wechat_breakeven_message( sym, direction, arm_txt, @@ -2888,7 +3678,9 @@ def check_order_monitors(): locked_r, new_sl, ) - ) + if ok_live: + be_msg += "\n- 交易所:已先撤后挂止盈止损" + send_wechat_msg(be_msg) res = None # 做多 @@ -3167,6 +3959,7 @@ def background_task(): conn.commit() conn.close() force_close_before_reset() + check_fib_key_monitors() check_key_monitors() check_order_monitors() except: @@ -3252,6 +4045,8 @@ def api_sync_positions(): def render_main_page(page="trade"): now = app_now() trading_day = get_trading_day(now) + list_window = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) conn = get_db() session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) @@ -3260,13 +4055,20 @@ def render_main_page(page="trade"): current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4) recommended_capital = get_recommended_capital(current_capital) key_list = conn.execute("SELECT * FROM key_monitors").fetchall() - key_history = conn.execute("SELECT * FROM key_monitor_history ORDER BY id DESC LIMIT 80").fetchall() + key_history = conn.execute( + "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() stats_bundle = compute_stats_bundle(conn, trading_day, now) raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() order_list = [] for o in raw_order_list: order_list.append(enrich_order_item(row_to_dict(o), current_capital)) - raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC").fetchall() + raw_records = conn.execute( + "SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() records = [to_effective_trade_dict(r) for r in raw_records] total = len(records) miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") @@ -3280,6 +4082,11 @@ def render_main_page(page="trade"): rate = round(win/total*100,2) if total else 0 active_count = len(order_list) can_trade = now.hour >= TRADING_DAY_RESET_HOUR and active_count == 0 + key_gate_rule_text = ( + f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" + f"斐波:添加后立即挂限价 @ E,失效按标记价触达 H/L(未成交撤单)" + ) conn.close() return render_template( "index.html", @@ -3312,7 +4119,14 @@ def render_main_page(page="trade"): can_trade=can_trade, focus_key_id=(key_list[0]["id"] if key_list else None), focus_order_id=(order_list[0]["id"] if order_list else None), - data_export_version=2, + data_export_version=3, + list_window=list_window, + list_window_presets={ + "utc_today": PRESET_UTC_TODAY, + "utc_last24h": PRESET_UTC_LAST24H, + "utc_last7d": PRESET_UTC_LAST7D, + "custom": PRESET_CUSTOM, + }, key_alert_max_times=KEY_ALERT_MAX_TIMES, risk_percent=RISK_PERCENT, breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, @@ -3320,6 +4134,9 @@ def render_main_page(page="trade"): occupied_miss_total=occupied_miss_total, price_fmt=format_price_for_symbol, entry_reason_options=list(ENTRY_REASON_OPTIONS), + entry_reason_other_value=ENTRY_REASON_OTHER, + key_gate_rule_text=key_gate_rule_text, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, ) @@ -3378,7 +4195,9 @@ def api_account_snapshot(): @login_required def api_price_snapshot(): conn = get_db() - key_rows = conn.execute("SELECT id,symbol,monitor_type,direction,upper,lower FROM key_monitors").fetchall() + key_rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id FROM key_monitors" + ).fetchall() order_rows = conn.execute( "SELECT id,symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status='active'" ).fetchall() @@ -3403,14 +4222,26 @@ def api_price_snapshot(): continue upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) + is_fib = is_fib_key_monitor_type(r["monitor_type"]) gate = None - try: - gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) - except Exception: - gate = None + if not is_fib: + try: + gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) + except Exception: + gate = None gate_summary = "-" gate_metrics = "" - if gate: + fib_gate_ok = True + if is_fib: + direction = (r["direction"] or "long").lower() + inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) + fib_gate_ok = not inval + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry is not None else "-" + gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" + if _sqlite_row_val(r, "fib_limit_order_id"): + gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" + elif gate: rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" gate_summary = ( f"量:{'Y' if gate.get('vol_ok') else 'N'} " @@ -3442,7 +4273,7 @@ def api_price_snapshot(): "lower_diff": lower_diff, "lower_pct": lower_pct, "gate_summary": gate_summary, - "gate_ok": bool(gate and gate.get("ok")), + "gate_ok": fib_gate_ok if is_fib else bool(gate and gate.get("ok")), "gate_metrics": gate_metrics, }) @@ -3739,16 +4570,50 @@ def add_key(): if not symbol: flash("symbol 不能为空") return redirect("/") + direction_sel = (d.get("direction") or "").strip().lower() + if direction_sel not in ("long", "short"): + flash("请选择做多或做空") + return redirect("/") + mt = (d.get("type") or "").strip() + allowed_types = tuple(KEY_MONITOR_AUTO_TYPES) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/") rank, total = _daily_volume_rank(symbol) if rank is None: flash("日成交量排名读取失败,请稍后重试") return redirect("/") - if rank > 30: - flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位") + if rank > KEY_DAILY_VOLUME_RANK_MAX: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") return redirect("/") conn = get_db() - conn.execute("INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", - (symbol, d["type"], d.get("direction", "long"), d["upper"], d["lower"])) + if mt in KEY_MONITOR_AUTO_TYPES: + if get_active_position_count(conn) > 0: + conn.close() + flash("当前已有持仓:无法添加「箱体突破 / 收敛突破」(请先平仓或使用阻力/支撑/斐波类型)") + return redirect("/") + ex_sym_key = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + uh = round_price_to_exchange(ex_sym_key, float(d["upper"])) + lw = round_price_to_exchange(ex_sym_key, float(d["lower"])) + upper_px = float(uh) if uh is not None else float(d["upper"]) + lower_px = float(lw) if lw is not None else float(d["lower"]) + if is_fib_key_monitor_type(mt): + ok_fib, err_fib = _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px) + conn.commit() + conn.close() + if not ok_fib: + flash(err_fib or "斐波监控添加失败") + return redirect("/") + flash(f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})") + return redirect("/") + conn.execute( + "INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px), + ) conn.commit() conn.close() flash(f"添加成功({symbol} 日成交量排名 {rank}/{total})") @@ -3906,12 +4771,14 @@ def add_order(): breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) else: breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 conn.execute( - "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, - notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day + breakeven_enabled, + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, "下单监控", ) ) conn.commit() @@ -4056,6 +4923,8 @@ def delete_key_monitor(kid): if not row: conn.close() return jsonify({"ok": False, "error": "not_found"}) + if is_fib_key_monitor_type((row["monitor_type"] or "").strip()): + _cancel_fib_monitor_limit(row) insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) conn.commit() @@ -4079,6 +4948,8 @@ def del_key(id): conn = get_db() row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() if row: + if is_fib_key_monitor_type((row["monitor_type"] or "").strip()): + _cancel_fib_monitor_limit(row) insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) conn.commit() @@ -4120,43 +4991,41 @@ def _md_response(filename, content): @app.route("/export/trade_records") @login_required def export_trade_records(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) conn = get_db() rows = conn.execute( - "SELECT id,symbol,monitor_type,direction,trigger_price,stop_loss,take_profit,margin_capital,leverage," - "pnl_amount,hold_seconds,hold_minutes,opened_at,closed_at,result,miss_reason," - "entry_reason,reviewed_entry_reason,created_at FROM trade_records ORDER BY id ASC" + "SELECT id,symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," + "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," + "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason,created_at " + "FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC", + (start_bj, end_bj), ).fetchall() conn.close() - head_base = [ - "id", - "symbol", - "monitor_type", - "direction", - "trigger_price", - "stop_loss", - "take_profit", - "margin_capital", - "leverage", - "pnl_amount", - "hold_seconds", - "hold_minutes", - "opened_at", - "closed_at", - "result", - "miss_reason", - "entry_reason", - "reviewed_entry_reason", - "created_at", + head = [ + "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", + "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", + "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", + "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", + "created_at", "开仓类型", ] - head = head_base + ["开仓类型"] data = [] for r in rows: er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" - eff = er1 or er0 - data.append(tuple(r[h] for h in head_base) + (eff,)) + kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" + eff = er1 or er0 or entry_reason_from_key_signal(kst) or "" + snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] + data.append(( + r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], + snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], + r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], + r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], + r["created_at"], eff, + )) day = app_now().strftime("%Y%m%d") - return _csv_response(f"trade_records_v2_{day}.csv", data, head) + return _csv_response(f"trade_records_v3_{day}.csv", data, head) @app.route("/export/journal_entries") @@ -4228,10 +5097,13 @@ def export_key_monitors(): @app.route("/export/key_monitor_history") @login_required def export_key_monitor_history(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) conn = get_db() rows = conn.execute( "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " - "FROM key_monitor_history ORDER BY id ASC" + "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", + (start_bj, end_bj), ).fetchall() conn.close() head = [ @@ -4393,9 +5265,9 @@ def add_miss(): @login_required def add_journal(): d = request.form - entry_reason_norm = normalize_entry_reason(d.get("entry_reason")) + entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom")) if not entry_reason_norm: - flash("请选择开仓类型(五种之一)") + flash("请选择开仓类型") return redirect("/records") early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) early_exit_note = str(d.get("early_exit_note") or "").strip() @@ -4441,9 +5313,10 @@ 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]}" + close_ms = _local_input_datetime_to_ms(d.get("close_datetime")) 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")), + "exit_ts_ms": close_ms, + "entry_ts_ms": close_ms, "entry_price": d.get("entry_price_hint"), "exit_price": None, } @@ -4458,7 +5331,11 @@ def add_journal(): filename=chart_fname, filename_prefix="journal", marker_payload=marker_payload, - marker_timeframes={"15m", "5m"}, + 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"} + ), ) if saved: image_filename = saved @@ -4504,8 +5381,14 @@ def add_journal(): @app.route("/api/journals") @login_required def api_journals(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) conn = get_db() - rows = conn.execute("SELECT * FROM journal_entries ORDER BY created_at DESC").fetchall() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE COALESCE(close_datetime, created_at, open_datetime) >= ? " + "AND COALESCE(close_datetime, created_at, open_datetime) <= ? ORDER BY created_at DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() conn.close() result = [] for r in rows: @@ -4553,8 +5436,13 @@ def delete_journal(jid): @app.route("/api/reviews") @login_required def api_reviews(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) conn = get_db() - rows = conn.execute("SELECT * FROM ai_reviews ORDER BY created_at DESC").fetchall() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", + (start_bj, end_bj), + ).fetchall() conn.close() return jsonify([row_to_dict(r) for r in rows]) diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index 18bb5f1..da5f0a2 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -103,6 +103,9 @@ .export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem} .export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a} .export-bar a:hover{background:#1f2740} + .list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem} + .list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px} + .stats-segment-block{margin-top:20px;padding-top:14px;border-top:1px solid #3a4468} .key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150} .key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px} .key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px} @@ -145,6 +148,23 @@ {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} +
+ 列表筛选(UTC,默认当日):{{ list_window.label }} + + + + + + + 统计页仍按北京时间 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 切日 +
数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列,复盘单独导出): 交易记录 @@ -178,6 +198,8 @@ @@ -188,12 +210,14 @@ +
{{ key_gate_rule_text }}
{% for k in key %}
{{ k.symbol }} | {{ k.monitor_type }} | {{ '做多' if k.direction == 'long' else '做空' }}
上:{{ k.upper }} 下:{{ k.lower }} + {% if k.fib_entry_price %}| 挂E:{{ k.fib_entry_price }}{% endif %} | 已提醒:{{ k.notification_count or 0 }}/{{ k.max_notify or 3 }} | 现价:- | 距上沿:- @@ -207,7 +231,7 @@

关键位历史(满次提醒或手动删除)

-
满 {{ key_alert_max_times }} 次企业微信提醒后自动移入此处;手动删除也会归档。
+
满 {{ key_alert_max_times }} 次企业微信提醒后自动移入此处;手动删除也会归档。受顶栏 UTC 列表时间窗筛选。
{% for h in key_history %}
@@ -276,6 +300,9 @@ + 成交价自动取交易所实时+成交回报 @@ -289,7 +316,7 @@
{{ o.symbol }} | {{ '做多' if o.direction == 'long' else '做空' }}
风格:{{ o.trade_style or 'trend' }} | 风险:{{ o.risk_percent or '-' }}%≈{{ o.risk_amount or '-' }}U - | 移动保本:{{ o.breakeven_rr_trigger or '-' }}R→{{ o.breakeven_price or '-' }} + | {% if o.breakeven_enabled %}移动保本:开{% else %}移动保本:关{% endif %} {{ o.breakeven_rr_trigger or '-' }}R→{{ o.breakeven_price or '-' }}
成交:{{ o.trigger_price }} 止损:{{ o.stop_loss }} 止盈:{{ o.take_profit }} | 盈亏比:{% if o.rr_ratio is not none %}1:{{ '%.2f'|format(o.rr_ratio) }}{% else %}-{% endif %} @@ -315,15 +342,15 @@
- + {% for r in record %} {% set pnl_val = (r.pnl_amount or 0)|float %} - + - {% set stop_show = r.effective_stop_loss or r.initial_stop_loss or r.stop_loss %} + {% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %} {% set tp_show = r.effective_take_profit or r.take_profit %} @@ -348,9 +375,10 @@ onclick='fillJournalFromTrade({{ { "symbol": r.symbol, "monitor_type": r.monitor_type, + "key_signal_type": r.key_signal_type or "", "direction": r.direction, "trigger_price": r.trigger_price, - "stop_loss": r.effective_stop_loss or r.initial_stop_loss or r.stop_loss, + "stop_loss": r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss, "take_profit": r.effective_take_profit or r.take_profit, "opened_at": r.effective_opened_at, "closed_at": r.effective_closed_at, @@ -418,16 +446,19 @@ - {% for er in entry_reason_options %} {% endfor %} + + + {% for seg in stats_bundle.segments %} + + {% endfor %} + + + + {% for seg in stats_bundle.segments %} + + {% endfor %} {% endif %} @@ -526,6 +571,107 @@
品种类型方向成交止损止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
品种类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
{{ r.symbol }}{{ r.monitor_type }}{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %} {{ '做多' if r.direction == 'long' else '做空' }} {{ r.trigger_price }}{{ price_fmt(r.symbol, stop_show) }} {{ price_fmt(r.symbol, tp_show) }}