diff --git a/app.py b/app.py index b6238ac..6ae6640 100644 --- a/app.py +++ b/app.py @@ -305,6 +305,7 @@ def init_db(): "ALTER TABLE key_monitors ADD COLUMN alert_break_side TEXT", "ALTER TABLE key_monitors ADD COLUMN breakout_bar_time TEXT", "ALTER TABLE key_monitors ADD COLUMN alert_close_price REAL", + "ALTER TABLE key_monitors ADD COLUMN bar_period TEXT DEFAULT '5m'", "ALTER TABLE review_records ADD COLUMN direction TEXT", "ALTER TABLE review_records ADD COLUMN entry_price REAL", "ALTER TABLE review_records ADD COLUMN stop_loss REAL", @@ -1057,6 +1058,8 @@ def ai_messages_page(): @app.route("/keys") @login_required def keys(): + from key_monitor_lib import key_monitor_periods + conn = get_db() key_list = conn.execute( "SELECT * FROM key_monitors WHERE status='active' OR status IS NULL ORDER BY id DESC" @@ -1065,7 +1068,12 @@ def keys(): "SELECT * FROM key_monitors WHERE status='archived' ORDER BY archived_at DESC LIMIT 100" ).fetchall() conn.close() - return render_template("keys.html", keys=key_list, history=history) + return render_template( + "keys.html", + keys=key_list, + history=history, + key_periods=key_monitor_periods(), + ) @@ -1103,21 +1111,26 @@ def add_key(): if trailing_be and risk_reward < 3: risk_reward = 3.0 + from key_monitor_lib import normalize_bar_period + + bar_period = normalize_bar_period(d.get("bar_period") or "5m") direction = (d.get("direction") or "").strip().lower() - if monitor_type in ("箱体突破", "收敛突破"): - direction = direction or "long" + if monitor_type == "箱体突破": + if direction not in ("long", "short"): + flash("箱体突破须选择上方向(做多/做空)") + return redirect(url_for("keys")) else: - direction = direction or "long" + direction = "" conn = get_db() conn.execute( """INSERT INTO key_monitors (symbol, symbol_name, market_code, sina_code, monitor_type, direction, - upper, lower, trade_mode, risk_reward, trailing_be) - VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + upper, lower, trade_mode, risk_reward, trailing_be, bar_period) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", ( symbol, symbol_name, market_code, sina_code, monitor_type, direction, - upper, lower, trade_mode, risk_reward, trailing_be, + upper, lower, trade_mode, risk_reward, trailing_be, bar_period, ), ) conn.commit() diff --git a/install_trading.py b/install_trading.py index 74c0e2d..2bca582 100644 --- a/install_trading.py +++ b/install_trading.py @@ -2933,6 +2933,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se def _execute_key_breakout(conn, row, bar, break_side): """关键位箱体/收敛:5m 收盘突破后自动市价开仓。""" from key_monitor_lib import ( + TYPE_BOX, calc_breakout_sl_tp, format_auto_breakout_msg, normalize_monitor_type, @@ -2966,6 +2967,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se detail=detail, )) + if monitor_type == TYPE_BOX: + cfg_dir = (row.get("direction") or "").strip().lower() + if cfg_dir in ("long", "short") and direction != cfg_dir: + dir_cn = "做多" if cfg_dir == "long" else "做空" + _notify(False, f"突破方向与上方向({dir_cn})不一致", entry=0, sl=0, tp=0, lots=0) + return False, "突破方向与上方向不一致" + try: init_strategy_tables(conn) mode = get_trading_mode(get_setting) diff --git a/key_monitor_lib.py b/key_monitor_lib.py index 6706f48..1a4c3c7 100644 --- a/key_monitor_lib.py +++ b/key_monitor_lib.py @@ -27,8 +27,38 @@ ZONE_TYPES = (TYPE_ZONE, "关键阻力位", "关键支撑位") ALERT_MAX_PUSH = 3 ALERT_INTERVAL_SEC = 300 SL_TICK_BUFFER = 2 -BAR_PERIOD = "5m" -BAR_MINUTES = 5 +DEFAULT_BAR_PERIOD = "5m" + +PERIOD_MINUTES_MAP = { + "1m": 1, "2m": 2, "3m": 3, "5m": 5, "15m": 15, "30m": 30, + "1h": 60, "2h": 120, "4h": 240, "d": 1440, "1d": 1440, +} + + +def key_monitor_periods() -> list[dict[str, str]]: + """关键位监控可选 K 线周期(触发用)。""" + from kline_chart import MARKET_PERIODS + + allowed = frozenset({"5m", "15m", "30m", "1h", "2h", "4h", "d"}) + return [p for p in MARKET_PERIODS if p["key"] in allowed] + + +def normalize_bar_period(raw: str) -> str: + valid = {p["key"] for p in key_monitor_periods()} + k = (raw or DEFAULT_BAR_PERIOD).strip() + return k if k in valid else DEFAULT_BAR_PERIOD + + +def bar_period_label(key: str) -> str: + k = normalize_bar_period(key) + for p in key_monitor_periods(): + if p["key"] == k: + return p["label"] + return k + + +def bar_period_minutes(period: str) -> int: + return PERIOD_MINUTES_MAP.get(normalize_bar_period(period), 5) def normalize_monitor_type(raw: str) -> str: @@ -95,14 +125,19 @@ def _parse_bar_time(raw: str) -> Optional[datetime]: return None -def last_closed_bar(bars: list[dict], now: Optional[datetime] = None) -> Optional[dict]: +def last_closed_bar( + bars: list[dict], + period_minutes: int = 5, + now: Optional[datetime] = None, +) -> Optional[dict]: """取最近一根已收盘 K 线。""" dnow = now or datetime.now(TZ) + mins = max(1, int(period_minutes or 5)) for bar in reversed(bars or []): dt = _parse_bar_time(str(bar.get("time") or "")) if not dt: continue - bar_end = dt + timedelta(minutes=BAR_MINUTES) + bar_end = dt + timedelta(minutes=mins) if dnow >= bar_end: return bar return None @@ -116,24 +151,26 @@ def detect_break_side(close: float, upper: float, lower: float) -> Optional[str] return None -def fetch_closed_5m_bar( +def fetch_closed_bar( sym: str, + period: str, *, db_path: str, trading_mode: str, ) -> Optional[dict]: + p = normalize_bar_period(period) try: data = fetch_market_klines( sym, - BAR_PERIOD, + p, db_path=db_path, trading_mode=trading_mode, prefer_ctp=True, ) bars = data.get("bars") or [] - return last_closed_bar(bars) + return last_closed_bar(bars, bar_period_minutes(p)) except Exception as exc: - logger.debug("key monitor kline %s: %s", sym, exc) + logger.debug("key monitor kline %s %s: %s", sym, p, exc) return None @@ -201,9 +238,10 @@ def format_auto_breakout_msg( break_label, _ = break_direction_label(break_side) dir_cn = "做多" if direction == "long" else "做空" rr = float(row.get("risk_reward") or 2) + period_label = bar_period_label(row.get("bar_period") or DEFAULT_BAR_PERIOD) lines = [ f"{'✅' if ok else '❌'} {name} {typ}自动单", - f"⏱ 5m 收盘:{bar_time}", + f"⏱ {period_label} 收盘:{bar_time}", f"🎯 {break_label} · {trade_mode} · {dir_cn}", f"💹 入场:{entry:g} 止损:{sl:g} 止盈:{tp:g}(盈亏比 {rr:g})", f"📦 手数:{lots}", @@ -333,7 +371,8 @@ def run_key_monitor_check( archive_monitor(conn, pid) continue - bar = fetch_closed_5m_bar(sym, db_path=db_path, trading_mode=mode) + bar_period = normalize_bar_period(row.get("bar_period") or DEFAULT_BAR_PERIOD) + bar = fetch_closed_bar(sym, bar_period, db_path=db_path, trading_mode=mode) if not bar: continue bar_time = str(bar.get("time") or "")[:19] diff --git a/static/css/keys.css b/static/css/keys.css index 35162fa..5666264 100644 --- a/static/css/keys.css +++ b/static/css/keys.css @@ -3,11 +3,20 @@ .key-rules-body{padding:.35rem 0 .15rem} .key-rules-body ul{margin:.25rem 0 .5rem 1.1rem;padding:0} .key-rules-body li{margin:.15rem 0} -.key-check{display:inline-flex;align-items:center;gap:.35rem;font-size:.82rem;flex:1;min-width:0;margin:0} -.key-check-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.line-key-actions{display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex-wrap:nowrap} -.line-key-actions.is-hidden{display:none!important} -.line-key-actions .key-submit-btn{flex-shrink:0;min-width:5.5rem;padding:.55rem 1.1rem} -.line-key-actions.key-actions-zone{justify-content:flex-end} -.line-key-actions.key-actions-zone .key-check{display:none} -#key-trade-mode-wrap.is-hidden,#key-rr-wrap.is-hidden{display:none!important} +.key-form-rows{display:flex;flex-direction:column;gap:.75rem;margin-bottom:.85rem} +.key-form-line{display:grid;gap:.65rem;align-items:end} +.key-form-line.line-3{grid-template-columns:1.4fr .85fr .85fr} +.key-form-line.line-2{grid-template-columns:1fr 1fr} +.key-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)} +.key-field select,.key-field input{width:100%;box-sizing:border-box} +.key-action-row{display:flex;flex-direction:column;gap:.35rem;margin-top:.15rem} +.key-action-row .trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin:0;cursor:pointer;user-select:none} +.key-action-row .trailing-be-toggle input{width:auto;margin:0;flex-shrink:0} +.key-trailing-hint{font-size:.72rem;margin:0;color:var(--text-muted);line-height:1.45} +.key-action-row .key-submit-btn{width:100%;padding:.65rem .75rem;font-size:.9rem;margin-top:.25rem} +#key-row-auto.is-hidden,#key-rr-wrap.is-hidden,#key-trade-mode-wrap.is-hidden,#key-direction-wrap.is-hidden,#key-trailing-wrap.is-hidden,#key-trailing-hint.is-hidden{display:none!important} +@media(max-width:720px){ + .key-form-line.line-3{grid-template-columns:1fr 1fr} + .key-form-line.line-3 .key-field:first-child{grid-column:1/-1} + .key-form-line.line-2{grid-template-columns:1fr} +} diff --git a/static/js/keys.js b/static/js/keys.js index 086f78d..0e8ea77 100644 --- a/static/js/keys.js +++ b/static/js/keys.js @@ -5,26 +5,35 @@ (function () { var keyTimer = null; var typeEl = document.getElementById('key-type'); + var rowAuto = document.getElementById('key-row-auto'); var tradeModeWrap = document.getElementById('key-trade-mode-wrap'); + var directionWrap = document.getElementById('key-direction-wrap'); var rrWrap = document.getElementById('key-rr-wrap'); var rrEl = document.getElementById('key-rr'); var trailingWrap = document.getElementById('key-trailing-wrap'); + var trailingHint = document.getElementById('key-trailing-hint'); var trailingEl = document.getElementById('key-trailing'); - var rowActions = document.getElementById('key-row-actions'); - var rowPrices = document.getElementById('key-row-prices'); + var directionEl = document.getElementById('key-direction'); function isAutoType(typ) { return typ === '箱体突破' || typ === '收敛突破'; } + function isBoxType(typ) { + return typ === '箱体突破'; + } + function syncKeyForm() { var typ = typeEl ? typeEl.value : ''; var auto = isAutoType(typ); + var box = isBoxType(typ); + if (rowAuto) rowAuto.classList.toggle('is-hidden', !auto); if (tradeModeWrap) tradeModeWrap.classList.toggle('is-hidden', !auto); if (rrWrap) rrWrap.classList.toggle('is-hidden', !auto); + if (directionWrap) directionWrap.classList.toggle('is-hidden', !box); if (trailingWrap) trailingWrap.classList.toggle('is-hidden', !auto); - if (rowActions) rowActions.classList.toggle('key-actions-zone', !auto); - if (rowPrices) rowPrices.classList.toggle('key-zone-mode', !auto); + if (trailingHint) trailingHint.classList.toggle('is-hidden', !auto); + if (directionEl) directionEl.disabled = !box; if (!auto && trailingEl) trailingEl.checked = false; if (auto && trailingEl && trailingEl.checked && rrEl) { if (parseFloat(rrEl.value) < 3) rrEl.value = '3'; diff --git a/templates/keys.html b/templates/keys.html index 50e7dc9..31a99b1 100644 --- a/templates/keys.html +++ b/templates/keys.html @@ -14,56 +14,88 @@

箱体突破 / 收敛突破(自动单)

关键支阻区(仅提醒)

-
-
-
- - - - - -
-
+ +
+
+
+ + + + + + +
+
+
+
+ + +
+
+ + +
- -
- +
+
+ + +
+
+ + +
+
+ + +
-
-
- - -
- +
+
+ + +
+
+ + +
+
+
+ +

开启后盈亏比默认 3,达 3R 自动止盈并启用移动止损

+
-
-
- -
@@ -72,8 +104,12 @@
{{ k.symbol_name or k.symbol }} {{ k.monitor_type }} + {{ k.bar_period or '5m' }} {% if k.monitor_type in ('箱体突破', '收敛突破') %} {{ k.trade_mode or '顺势' }} + {% if k.monitor_type == '箱体突破' and k.direction %} + {{ '做多' if k.direction == 'long' else '做空' }} + {% endif %} {% endif %} {% if k.trailing_be %} 移动保本 @@ -101,15 +137,20 @@

监控历史

- + {% for k in history %} + {% else %} - + {% endfor %}
品种类型模式上沿下沿归档
品种类型周期模式上沿下沿归档
{{ k.symbol_name or k.symbol }} {{ k.monitor_type }}{{ k.bar_period or '5m' }} {% if k.monitor_type in ('箱体突破', '收敛突破') %} - {{ k.trade_mode or '顺势' }}{% if k.trailing_be %} · 移动保本{% endif %} + {{ k.trade_mode or '顺势' }} + {% if k.monitor_type == '箱体突破' and k.direction %} + · {{ '做多' if k.direction == 'long' else '做空' }} + {% endif %} + {% if k.trailing_be %} · 移动保本{% endif %} {% elif k.monitor_type in ('关键阻力位', '关键支撑位') %} 支阻区 {% else %} @@ -121,7 +162,7 @@ {{ k.archived_at[:16] if k.archived_at else '' }}
暂无历史
暂无历史