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 = '
| 品种 | 类型 | 方向 | 上沿 | 下沿 | 归档时间 |
|---|---|---|---|---|---|
| {{ 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 '' }} | +
| 暂无历史 | |||||
计划仅当日有效,次日 0 点自动失效并归入历史;触发止盈/止损后标记为已完成。
-开盘前制定,当日有效;下方为进行中计划。
+ +| 日期 | 品种 | 方向 | 区间 | 状态 |
|---|---|---|---|---|
| {{ 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.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 %} - | -删 | -
| 暂无历史记录 | |||||||