diff --git a/app.py b/app.py index de83e51..eb13986 100644 --- a/app.py +++ b/app.py @@ -3,7 +3,7 @@ import sqlite3 import time import threading import requests -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional from functools import wraps from zoneinfo import ZoneInfo @@ -44,6 +44,46 @@ def today_str() -> str: return datetime.now(TZ).date().isoformat() +def calc_holding_duration(open_time: str, close_time: str) -> str: + try: + o = datetime.fromisoformat(open_time.strip()) + c = datetime.fromisoformat(close_time.strip()) + delta = c - o + if delta.total_seconds() < 0: + return "" + secs = int(delta.total_seconds()) + h, rem = divmod(secs, 3600) + m, _ = divmod(rem, 60) + if h: + return f"{h}小时{m}分钟" + return f"{m}分钟" + except Exception: + return "" + + +def calc_theoretical_pnl(direction: str, entry: float, target: float, lots: float) -> Optional[float]: + if entry is None or target is None or lots is None: + return None + if direction == "long": + return round((target - entry) * lots, 2) + if direction == "short": + return round((entry - target) * lots, 2) + return None + + +def parse_review_date_filter(preset: str, start: str, end: str) -> tuple[str, str]: + today = datetime.now(TZ).date() + if preset == "today": + s = today.isoformat() + return s, s + if preset == "week": + monday = today - timedelta(days=today.weekday()) + return monday.isoformat(), today.isoformat() + if preset == "month": + return today.replace(day=1).isoformat(), today.isoformat() + return start.strip(), end.strip() + + def expire_old_plans(): """当日结束后计划自动失效,保留历史。""" today = today_str() @@ -118,6 +158,18 @@ def init_db(): "ALTER TABLE key_monitors ADD COLUMN market_code TEXT", "ALTER TABLE trade_records ADD COLUMN market_code TEXT", "ALTER TABLE order_plans ADD COLUMN plan_date TEXT", + "ALTER TABLE key_monitors ADD COLUMN status TEXT DEFAULT 'active'", + "ALTER TABLE key_monitors ADD COLUMN archived_at TEXT", + "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", + "ALTER TABLE review_records ADD COLUMN take_profit REAL", + "ALTER TABLE review_records ADD COLUMN close_price REAL", + "ALTER TABLE review_records ADD COLUMN lots REAL", + "ALTER TABLE review_records ADD COLUMN holding_duration TEXT", + "ALTER TABLE review_records ADD COLUMN initial_pnl REAL", + "ALTER TABLE review_records ADD COLUMN actual_pnl REAL", + "ALTER TABLE review_records ADD COLUMN is_emotion INTEGER DEFAULT 0", ] for sql in migrations: try: @@ -309,7 +361,9 @@ def check_order_plans(): def check_key_monitors(): conn = get_db() - rows = conn.execute("SELECT * FROM key_monitors").fetchall() + rows = conn.execute( + "SELECT * FROM key_monitors WHERE status='active' OR status IS NULL" + ).fetchall() for r in rows: sym = r["symbol"] @@ -493,9 +547,14 @@ def del_plan(pid): @login_required def keys(): conn = get_db() - key_list = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + key_list = conn.execute( + "SELECT * FROM key_monitors WHERE status='active' OR status IS NULL ORDER BY id DESC" + ).fetchall() + history = conn.execute( + "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) + return render_template("keys.html", keys=key_list, history=history) @app.route("/add_key", methods=["POST"]) @@ -530,18 +589,24 @@ def add_key(): @login_required def del_key(pid): conn = get_db() - conn.execute("DELETE FROM key_monitors WHERE id=?", (pid,)) + conn.execute( + "UPDATE key_monitors SET status='archived', archived_at=? WHERE id=?", + (datetime.now(TZ).isoformat(), pid), + ) conn.commit() conn.close() - flash("已删除") + flash("已移入监控历史") return redirect(url_for("keys")) @app.route("/records") @login_required def records(): + preset = request.args.get("preset", "") start = request.args.get("start", "") end = request.args.get("end", "") + if preset: + start, end = parse_review_date_filter(preset, start, end) conn = get_db() sql = "SELECT * FROM review_records WHERE 1=1" @@ -556,7 +621,7 @@ def records(): review_list = conn.execute(sql, params).fetchall() auto_list = conn.execute( - "SELECT * FROM trade_records ORDER BY id DESC LIMIT 50" + "SELECT * FROM trade_records ORDER BY id DESC LIMIT 30" ).fetchall() conn.close() @@ -564,6 +629,7 @@ def records(): "records.html", reviews=review_list, auto_records=auto_list, + preset=preset, start=start, end=end, open_types=OPEN_TYPES, @@ -596,6 +662,7 @@ def add_review(): f.save(os.path.join(UPLOAD_DIR, screenshot)) tags = [t for t in BEHAVIOR_TAGS if d.get(f"tag_{t}")] + is_emotion = 1 if tags else 0 def num(key: str) -> Optional[float]: v = d.get(key, "").strip() @@ -603,21 +670,37 @@ def add_review(): return None return float(v) + open_time = d.get("open_time", "").strip() + close_time = d.get("close_time", "").strip() + direction = d.get("direction", "").strip() + entry_price = num("entry_price") + stop_loss = num("stop_loss") + take_profit = num("take_profit") + close_price = num("close_price") + lots = num("lots") or 1.0 + + holding = calc_holding_duration(open_time, close_time) + initial_pnl = calc_theoretical_pnl(direction, entry_price, take_profit, lots) + actual_pnl = calc_theoretical_pnl(direction, entry_price, close_price, lots) + conn = get_db() conn.execute( """INSERT INTO review_records - (open_time, close_time, symbol, timeframe, pnl, + (open_time, close_time, symbol, timeframe, direction, + entry_price, stop_loss, take_profit, close_price, lots, + holding_duration, initial_pnl, actual_pnl, pnl, open_type, expected_rr, actual_rr, exit_trigger, exit_supplement, watch_after_breakeven, new_position_while_occupied, screenshot, auto_kline, kline_period1, kline_period2, kline_count, kline_cutoff, - behavior_tags, notes) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + behavior_tags, is_emotion, notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( - d.get("open_time", "").strip(), - d.get("close_time", "").strip(), + open_time, close_time, d.get("symbol", "").strip(), d.get("timeframe", "").strip(), - num("pnl"), + direction, + entry_price, stop_loss, take_profit, close_price, lots, + holding, initial_pnl, actual_pnl, num("pnl"), open_type, num("expected_rr"), num("actual_rr"), @@ -632,6 +715,7 @@ def add_review(): int(d.get("kline_count") or 300), d.get("kline_cutoff", "平仓时间"), ",".join(tags), + is_emotion, d.get("notes", "").strip(), ), ) diff --git a/static/js/review.js b/static/js/review.js new file mode 100644 index 0000000..0c1743e --- /dev/null +++ b/static/js/review.js @@ -0,0 +1,106 @@ +(function () { + function parseNum(v) { + var n = parseFloat(v); + return isNaN(n) ? null : n; + } + + function calcPnl(direction, entry, target, lots) { + if (!entry || !target || !lots) return ''; + if (direction === 'long') return ((target - entry) * lots).toFixed(2); + if (direction === 'short') return ((entry - target) * lots).toFixed(2); + return ''; + } + + function calcDuration(openVal, closeVal) { + if (!openVal || !closeVal) return ''; + var o = new Date(openVal); + var c = new Date(closeVal); + var secs = Math.floor((c - o) / 1000); + if (secs < 0) return ''; + var h = Math.floor(secs / 3600); + var m = Math.floor((secs % 3600) / 60); + return h ? h + '小时' + m + '分钟' : m + '分钟'; + } + + function recalc() { + var form = document.getElementById('review-form'); + if (!form) return; + var dir = form.querySelector('[name="direction"]').value; + var entry = parseNum(form.querySelector('[name="entry_price"]').value); + var sl = parseNum(form.querySelector('[name="stop_loss"]').value); + var tp = parseNum(form.querySelector('[name="take_profit"]').value); + var close = parseNum(form.querySelector('[name="close_price"]').value); + var lots = parseNum(form.querySelector('[name="lots"]').value) || 1; + var openT = form.querySelector('[name="open_time"]').value; + var closeT = form.querySelector('[name="close_time"]').value; + + var hold = document.getElementById('holding_duration'); + var initP = document.getElementById('initial_pnl'); + var actP = document.getElementById('actual_pnl'); + if (hold) hold.value = calcDuration(openT, closeT); + if (initP) initP.value = calcPnl(dir, entry, tp, lots); + if (actP) actP.value = calcPnl(dir, entry, close, lots); + } + + function bindForm() { + var form = document.getElementById('review-form'); + if (!form) return; + form.querySelectorAll('input, select').forEach(function (el) { + el.addEventListener('input', recalc); + el.addEventListener('change', recalc); + }); + } + + function showModal(data) { + var mask = document.getElementById('review-modal'); + var body = document.getElementById('review-modal-body'); + if (!mask || !body) return; + var html = ''; + if (data.screenshot) { + html += '
'; + } + body.innerHTML = html; + mask.classList.add('show'); + } + + function bindModal() { + var mask = document.getElementById('review-modal'); + if (!mask) return; + mask.querySelector('.modal-close').addEventListener('click', function () { + mask.classList.remove('show'); + }); + mask.addEventListener('click', function (e) { + if (e.target === mask) mask.classList.remove('show'); + }); + document.querySelectorAll('.review-view-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + try { + showModal(JSON.parse(btn.getAttribute('data-review'))); + } catch (e) { /* ignore */ } + }); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + bindForm(); + bindModal(); + recalc(); + }); +})(); diff --git a/templates/base.html b/templates/base.html index 7e04c38..5eaccc2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -63,6 +63,28 @@ .filter-row{display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end;margin-bottom:1rem} .filter-row .field{width:auto;min-width:140px} .filter-row button{width:auto} + .split-grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:stretch;margin-bottom:1.5rem} + .split-grid .card{margin-bottom:0;height:100%;min-height:560px;display:flex;flex-direction:column} + .split-grid .card-body{flex:1;overflow:auto} + .card-scroll{max-height:420px;overflow-y:auto} + .preset-tabs{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem} + .preset-tabs a{padding:.45rem .85rem;border-radius:8px;border:1px solid #2e2e45;color:#a9a9c4;text-decoration:none;font-size:.85rem} + .preset-tabs a.active,.preset-tabs a:hover{background:#1e2533;color:#4cc2ff;border-color:#4cc2ff} + .btn-link{color:#4cc2ff;cursor:pointer;font-size:.85rem;background:none;border:none;padding:0} + .btn-link:hover{text-decoration:underline} + .modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:1000;display:none;align-items:center;justify-content:center;padding:1rem} + .modal-mask.show{display:flex} + .modal-box{background:#12121a;border:1px solid #242435;border-radius:16px;max-width:900px;width:100%;max-height:90vh;overflow:auto;padding:1.5rem} + .modal-box h3{margin-bottom:1rem;color:#c4c4ff} + .modal-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.75rem;font-size:.9rem} + .modal-grid .item label{color:#888;font-size:.75rem;display:block} + .modal-grid .item div{margin-top:.2rem} + .modal-close{float:right;color:#888;cursor:pointer;font-size:1.2rem} + .calc-readonly{background:#161625;color:#4cc2ff} + @media(max-width:1100px){ + .split-grid{grid-template-columns:1fr} + .split-grid .card{min-height:auto} + } @media(max-width:768px){ .topbar-inner{flex-wrap:wrap;height:auto;padding:.75rem 0} .nav{order:3;width:100%} diff --git a/templates/keys.html b/templates/keys.html index c6c6f68..c09a2a1 100644 --- a/templates/keys.html +++ b/templates/keys.html @@ -3,51 +3,74 @@ {% block content %}

关键位监控

-
-

新增监控

-
-
- - - - - -
-
-
- - - - - -
-
- -
-

监控列表

-
- {% for k in keys %} -
-
- {{ k.symbol_name or k.symbol }} | {{ k.monitor_type }} - {{ '做多' if k.direction == 'long' else '做空' }} +
+
+

新增监控

+
+
+
+ + + + + +
+
+
+ + + + + +
+

监控列表

+
+ {% for k in keys %} +
+
+ {{ k.symbol_name or k.symbol }} | {{ k.monitor_type }} + {{ '做多' if k.direction == 'long' else '做空' }} +
+
上 {{ k.upper }} | 下 {{ k.lower }}
+ 删除 +
+ {% else %} +
暂无监控
+ {% endfor %}
-
上: {{ k.upper }} | 下: {{ k.lower }}
-
同花顺: {{ k.symbol }}
- 删除
- {% else %} -
暂无关键位监控
- {% endfor %} +
+ +
+

监控历史

+
+ + + + {% for k in history %} + + + + + + + + + {% else %} + + {% endfor %} + +
品种类型方向上沿下沿归档时间
{{ k.symbol_name or k.symbol }}{{ k.monitor_type }}{{ '多' if k.direction == 'long' else '空' }}{{ k.upper }}{{ k.lower }}{{ k.archived_at[:16] if k.archived_at else '' }}
暂无历史
+
{% endblock %} diff --git a/templates/plans.html b/templates/plans.html index 6e24810..e9d6bea 100644 --- a/templates/plans.html +++ b/templates/plans.html @@ -3,104 +3,84 @@ {% block content %}

开单计划 今日 {{ today }}

-
-

今日计划(开盘前制定,当日有效)

-
-
- - - - - -
-
-
- - - - - - -
-

计划仅当日有效,次日 0 点自动失效并归入历史;触发止盈/止损后标记为已完成。

-
- -
-

今日进行中

-
- {% for p in plans %} -
-
- {{ p.symbol_name or p.symbol }} - {{ '做多' if p.direction == 'long' else '做空' }} - {% if p.status == 'planned' %} - 待触发 +
+
+

今日计划

+
+

开盘前制定,当日有效;下方为进行中计划。

+
+
+ + + + + +
+
+
+ + + + + + +
+

进行中

+
+ {% for p in plans %} +
+
+ {{ p.symbol_name or p.symbol }} + {{ '做多' if p.direction == 'long' else '做空' }} + {% if p.status == 'planned' %}待触发 + {% else %}已激活{% endif %} +
+
区间 {{ p.zone_lower }}~{{ p.zone_upper }} | 损{{ p.stop_loss }} 盈{{ p.take_profit }}
+ 删除 +
{% else %} - 已激活 - {% endif %} +
今日暂无进行中的计划
+ {% endfor %} +
+
+
+ +
+

历史计划

+
+
+
+
+ + 重置 +
+
+ + + + {% for p in history %} + + + + + + + + {% else %} + + {% endfor %} + +
日期品种方向区间状态
{{ p.plan_date or '' }}{{ p.symbol_name or p.symbol }}{{ '多' if p.direction == 'long' else '空' }}{{ p.zone_lower }}~{{ p.zone_upper }} + {% if p.status == 'closed' %}完成 + {% elif p.status == 'expired' %}失效 + {% else %}{{ p.status }}{% endif %} +
暂无历史
-
区间: {{ p.zone_lower }} ~ {{ p.zone_upper }}
-
止损: {{ p.stop_loss }} | 止盈: {{ p.take_profit }}
-
同花顺: {{ p.symbol }}
- 删除
- {% else %} -
今日暂无开单计划
- {% endfor %}
- -
-

历史计划

-
-
- - -
-
- - -
- - 重置 -
- - - - - - - - - - - - - - - {% for p in history %} - - - - - - - - - - - {% else %} - - {% endfor %} - -
日期品种方向区间止损止盈状态
{{ p.plan_date or '' }}{{ p.symbol_name or p.symbol }}{{ '做多' if p.direction == 'long' else '做空' }}{{ p.zone_lower }} ~ {{ p.zone_upper }}{{ p.stop_loss }}{{ p.take_profit }} - {% if p.status == 'closed' %}已完成 - {% elif p.status == 'expired' %}已失效 - {% else %}{{ p.status }}{% endif %} -
暂无历史记录
-
{% endblock %} diff --git a/templates/records.html b/templates/records.html index c19b041..c8eff60 100644 --- a/templates/records.html +++ b/templates/records.html @@ -1,211 +1,166 @@ {% extends "base.html" %} {% block title %}交易记录与复盘 - 国内期货监控系统{% endblock %} {% block content %} -

交易复盘记录上传(含截图)

+

交易记录与复盘

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

下方勾选行为标签的均为情绪单

+
+ {% for tag in behavior_tags %} + {% endfor %} - +
+
+ +
+
+
+ +
+

复盘历史

+
+ -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - + {% if preset == 'custom' or start or end %} +
+ +
+
+ +
+ {% endif %} +
+ + + + + + + + {% for r in reviews %} + + + + + + + + + + {% else %} + + {% endfor %} + +
平仓品种方向盈亏情绪单详情
{{ r.close_time[:16] if r.close_time else '' }}{{ r.symbol }}{{ '多' if r.direction == 'long' else '空' }} + {% if r.pnl and r.pnl > 0 %}{{ r.pnl }} + {% elif r.pnl and r.pnl < 0 %}{{ r.pnl }} + {% else %}{{ r.actual_pnl or '-' }}{% endif %} + {% if r.is_emotion %}情绪{% else %}-{% endif %} + +
暂无复盘记录
- -
- -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-

勾选自动生成时,将按所选周期上下排列 K 线图;截止时间为平仓/开仓/当前,用于截取行情片段。

- -
- {% for tag in behavior_tags %} - - {% endfor %} -
- -
- - -
- - - +
-
-

复盘历史

-
-
- - -
-
- - -
- - 重置 -
- - - - - - - - - - - - - - - - - - - {% for r in reviews %} - - - - - - - - - - - - - - - {% else %} - - {% endfor %} - -
开仓平仓品种周期盈亏开仓类型预期RR实际RR离场行为标签截图
{{ r.open_time[:16] if r.open_time else '' }}{{ r.close_time[:16] if r.close_time else '' }}{{ r.symbol }}{{ r.timeframe }} - {% if r.pnl and r.pnl > 0 %}{{ r.pnl }} - {% elif r.pnl and r.pnl < 0 %}{{ r.pnl }} - {% else %}{{ r.pnl or '-' }}{% endif %} - {{ r.open_type }}{{ r.expected_rr or '-' }}{{ r.actual_rr or '-' }}{{ r.exit_trigger }}{{ r.behavior_tags or '-' }} - {% if r.screenshot %} - 查看 - {% else %}-{% endif %} -
暂无复盘记录
+ {% if auto_records %} -
+

系统自动记录(止盈/止损)

- - - + {% for r in auto_records %} - + - + {% endfor %} @@ -214,3 +169,6 @@ {% endif %} {% endblock %} +{% block extra_js %} + +{% endblock %}
品种类型方向触发价结果时间
品种类型方向触发价结果时间
{{ r.symbol_name or r.symbol }} {{ r.monitor_type }}{{ '做多' if r.direction == 'long' else '做空' }}{{ '多' if r.direction == 'long' else '空' }} {{ r.trigger_price }} - {% if r.result == '止盈' %}止盈 - {% else %}止损{% endif %} - {% if r.result == '止盈' %}止盈{% else %}止损{% endif %} {{ r.created_at[:16] if r.created_at else '' }}