diff --git a/app.py b/app.py
index eb13986..c93572a 100644
--- a/app.py
+++ b/app.py
@@ -18,6 +18,7 @@ from flask import (
from werkzeug.security import check_password_hash, generate_password_hash
from symbols import search_symbols, ths_to_codes
+from kline_chart import generate_review_kline_chart
from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
@@ -61,6 +62,23 @@ def calc_holding_duration(open_time: str, close_time: str) -> str:
return ""
+def calc_rr_ratio(direction: str, entry: float, stop: float, target: float) -> Optional[float]:
+ """盈亏比 = 盈利空间 / 风险空间。"""
+ if entry is None or stop is None or target is None:
+ return None
+ if direction == "long":
+ risk = entry - stop
+ if risk <= 0:
+ return None
+ return round((target - entry) / risk, 2)
+ if direction == "short":
+ risk = stop - entry
+ if risk <= 0:
+ return None
+ return round((entry - target) / risk, 2)
+ return None
+
+
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
@@ -680,8 +698,29 @@ def add_review():
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)
+ initial_pnl = calc_rr_ratio(direction, entry_price, stop_loss, take_profit)
+ actual_pnl = calc_rr_ratio(direction, entry_price, stop_loss, close_price)
+
+ auto_kline = bool(d.get("auto_kline"))
+ if auto_kline and not screenshot:
+ try:
+ generated = generate_review_kline_chart(
+ symbol=d.get("symbol", "").strip(),
+ periods=[d.get("kline_period1", "15m"), d.get("kline_period2", "1h")],
+ count=int(d.get("kline_count") or 300),
+ cutoff_label=d.get("kline_cutoff", "平仓时间"),
+ open_time=open_time,
+ close_time=close_time,
+ entry_price=entry_price,
+ stop_loss=stop_loss,
+ take_profit=take_profit,
+ close_price=close_price,
+ upload_dir=UPLOAD_DIR,
+ )
+ if generated:
+ screenshot = generated
+ except Exception as exc:
+ app.logger.warning("auto kline failed: %s", exc)
conn = get_db()
conn.execute(
@@ -702,14 +741,14 @@ def add_review():
entry_price, stop_loss, take_profit, close_price, lots,
holding, initial_pnl, actual_pnl, num("pnl"),
open_type,
- num("expected_rr"),
- num("actual_rr"),
+ None,
+ None,
exit_trigger,
d.get("exit_supplement", "").strip(),
d.get("watch_after_breakeven", "否"),
d.get("new_position_while_occupied", "否"),
screenshot,
- 1 if d.get("auto_kline") else 0,
+ 1 if auto_kline else 0,
d.get("kline_period1", "15m"),
d.get("kline_period2", "1h"),
int(d.get("kline_count") or 300),
diff --git a/kline_chart.py b/kline_chart.py
new file mode 100644
index 0000000..73bafc8
--- /dev/null
+++ b/kline_chart.py
@@ -0,0 +1,257 @@
+"""复盘 K 线:新浪拉取 + matplotlib 生成截图。"""
+import json
+import logging
+import os
+import re
+from datetime import datetime
+from typing import Optional
+from zoneinfo import ZoneInfo
+
+import requests
+
+from symbols import ths_to_codes
+
+logger = logging.getLogger(__name__)
+TZ = ZoneInfo("Asia/Shanghai")
+
+PERIOD_MINUTES = {
+ "1m": "1",
+ "3m": "3",
+ "5m": "5",
+ "15m": "15",
+ "30m": "30",
+ "1h": "60",
+ "4h": "240",
+}
+
+
+def ths_to_sina_chart_symbol(symbol: str) -> Optional[str]:
+ """ag2608 -> AG2608(新浪 K 线接口合约代码)。"""
+ code = (symbol or "").strip()
+ if not code:
+ return None
+ codes = ths_to_codes(code)
+ if codes:
+ sina = codes.get("sina_code", "")
+ if sina.startswith("nf_"):
+ return sina[3:]
+ if sina.startswith("CFF_RE_"):
+ return sina[7:]
+ ths = codes.get("ths_code", "")
+ return ths.upper() if ths else None
+ m = re.match(r"^([A-Za-z]+)(\d+)$", code)
+ if m:
+ return m.group(1).upper() + m.group(2)
+ return None
+
+
+def _parse_jsonp(text: str) -> Optional[list]:
+ m = re.search(r"\((.*)\)\s*;?\s*$", text.strip(), re.DOTALL)
+ if not m:
+ return None
+ try:
+ data = json.loads(m.group(1))
+ return data if isinstance(data, list) else None
+ except json.JSONDecodeError:
+ return None
+
+
+def fetch_sina_klines(symbol: str, period: str) -> list:
+ """拉取新浪期货分钟 K 线。"""
+ chart_sym = ths_to_sina_chart_symbol(symbol)
+ if not chart_sym:
+ return []
+ if period == "1d":
+ return _fetch_sina_daily(chart_sym)
+ typ = PERIOD_MINUTES.get(period)
+ if not typ:
+ return []
+ ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
+ url = (
+ "https://stock2.finance.sina.com.cn/futures/api/jsonp.php/"
+ f"var_{chart_sym}_{typ}_{ts}=/InnerFuturesNewService.getFewMinLine"
+ f"?symbol={chart_sym}&type={typ}"
+ )
+ try:
+ resp = requests.get(
+ url,
+ timeout=20,
+ headers={"Referer": "https://finance.sina.com.cn"},
+ )
+ bars = _parse_jsonp(resp.text)
+ return bars or []
+ except Exception as exc:
+ logger.warning("fetch kline failed %s %s: %s", chart_sym, period, exc)
+ return []
+
+
+def _fetch_sina_daily(chart_sym: str) -> list:
+ url = (
+ "https://stock2.finance.sina.com.cn/futures/api/json.php/"
+ f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}"
+ )
+ try:
+ resp = requests.get(url, timeout=20, headers={"Referer": "https://finance.sina.com.cn"})
+ raw = resp.json()
+ if not raw:
+ return []
+ out = []
+ for row in raw:
+ if isinstance(row, list) and len(row) >= 5:
+ out.append({
+ "d": row[0],
+ "o": row[1],
+ "h": row[2],
+ "l": row[3],
+ "c": row[4],
+ })
+ return out
+ except Exception as exc:
+ logger.warning("fetch daily kline failed %s: %s", chart_sym, exc)
+ return []
+
+
+def _parse_dt(value: str) -> Optional[datetime]:
+ if not value:
+ return None
+ v = value.strip().replace("T", " ")
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
+ try:
+ return datetime.strptime(v, fmt).replace(tzinfo=TZ)
+ except ValueError:
+ continue
+ try:
+ return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ)
+ except ValueError:
+ return None
+
+
+def _bar_datetime(bar: dict) -> Optional[datetime]:
+ d = bar.get("d")
+ if not d:
+ return None
+ try:
+ return datetime.strptime(d, "%Y-%m-%d %H:%M:%S").replace(tzinfo=TZ)
+ except ValueError:
+ return None
+
+
+def _select_bars(
+ bars: list,
+ cutoff: datetime,
+ count: int,
+) -> list:
+ filtered = []
+ for bar in bars:
+ dt = _bar_datetime(bar)
+ if dt and dt <= cutoff:
+ filtered.append(bar)
+ if not filtered:
+ filtered = bars
+ if count > 0 and len(filtered) > count:
+ filtered = filtered[-count:]
+ return filtered
+
+
+def generate_review_kline_chart(
+ symbol: str,
+ periods: list[str],
+ count: int,
+ cutoff_label: str,
+ open_time: str,
+ close_time: str,
+ entry_price: Optional[float],
+ stop_loss: Optional[float],
+ take_profit: Optional[float],
+ close_price: Optional[float],
+ upload_dir: str,
+) -> Optional[str]:
+ """生成双周期 K 线复盘图,返回 uploads 目录下的文件名。"""
+ import matplotlib
+ matplotlib.use("Agg")
+ import matplotlib.pyplot as plt
+ import matplotlib.dates as mdates
+
+ now = datetime.now(TZ)
+ if cutoff_label == "开仓时间":
+ cutoff = _parse_dt(open_time) or now
+ elif cutoff_label == "当前时间":
+ cutoff = now
+ else:
+ cutoff = _parse_dt(close_time) or now
+
+ open_dt = _parse_dt(open_time)
+ close_dt = _parse_dt(close_time)
+
+ valid_periods = [p for p in periods if p]
+ if not valid_periods:
+ valid_periods = ["15m", "1h"]
+
+ fig, axes = plt.subplots(
+ len(valid_periods), 1,
+ figsize=(14, 4.5 * len(valid_periods)),
+ facecolor="#0a0a10",
+ squeeze=False,
+ )
+
+ plotted = False
+ for idx, period in enumerate(valid_periods):
+ ax = axes[idx, 0]
+ bars = fetch_sina_klines(symbol, period)
+ bars = _select_bars(bars, cutoff, count)
+ if not bars:
+ ax.set_facecolor("#12121a")
+ ax.text(0.5, 0.5, f"No {period} data", ha="center", va="center", color="#888")
+ ax.set_xticks([])
+ ax.set_yticks([])
+ continue
+
+ times = [_bar_datetime(b) for b in bars]
+ closes = [float(b["c"]) for b in bars]
+ highs = [float(b["h"]) for b in bars]
+ lows = [float(b["l"]) for b in bars]
+
+ ax.set_facecolor("#12121a")
+ ax.plot(times, closes, color="#4cc2ff", linewidth=1.2)
+ ax.fill_between(
+ times, lows, highs,
+ color="#4cc2ff", alpha=0.12,
+ )
+
+ levels = [
+ (entry_price, "#eac147", "Entry"),
+ (stop_loss, "#ff6666", "SL"),
+ (take_profit, "#4cd97f", "TP"),
+ (close_price, "#c4c4ff", "Close"),
+ ]
+ for price, color, label in levels:
+ if price is not None:
+ ax.axhline(price, color=color, linewidth=0.9, linestyle="--", alpha=0.85)
+ ax.text(times[-1], price, label, color=color, fontsize=8, va="bottom")
+
+ if open_dt:
+ ax.axvline(open_dt, color="#888", linewidth=0.8, linestyle=":", alpha=0.7)
+ if close_dt:
+ ax.axvline(close_dt, color="#aaa", linewidth=0.8, linestyle=":", alpha=0.7)
+
+ chart_sym = ths_to_sina_chart_symbol(symbol) or symbol
+ ax.set_title(f"{chart_sym} {period}", color="#eaeaea", fontsize=11, pad=8)
+ ax.tick_params(colors="#888", labelsize=8)
+ for spine in ax.spines.values():
+ spine.set_color("#2e2e45")
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M"))
+ ax.grid(True, color="#1e1e30", linewidth=0.5)
+ plotted = True
+
+ if not plotted:
+ plt.close(fig)
+ return None
+
+ fig.tight_layout()
+ ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
+ chart_sym = ths_to_sina_chart_symbol(symbol) or "chart"
+ filename = f"{ts}_kline_{chart_sym}.png"
+ path = os.path.join(upload_dir, filename)
+ fig.savefig(path, dpi=120, facecolor=fig.get_facecolor())
+ plt.close(fig)
+ return filename
diff --git a/requirements.txt b/requirements.txt
index 563b72a..47b704c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,3 +2,4 @@ Flask==3.0.3
requests==2.32.3
python-dotenv==1.0.1
Werkzeug==3.0.3
+matplotlib==3.9.2
diff --git a/static/js/review.js b/static/js/review.js
index 0c1743e..6fb3592 100644
--- a/static/js/review.js
+++ b/static/js/review.js
@@ -4,11 +4,20 @@
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 calcRR(direction, entry, stop, target) {
+ if (!entry || !stop || !target) return '';
+ var risk, reward;
+ if (direction === 'long') {
+ risk = entry - stop;
+ reward = target - entry;
+ } else if (direction === 'short') {
+ risk = stop - entry;
+ reward = entry - target;
+ } else {
+ return '';
+ }
+ if (risk <= 0) return '';
+ return (reward / risk).toFixed(2);
}
function calcDuration(openVal, closeVal) {
@@ -30,16 +39,15 @@
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');
+ var initR = document.getElementById('initial_rr');
+ var actR = document.getElementById('actual_rr');
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);
+ if (initR) initR.value = calcRR(dir, entry, sl, tp);
+ if (actR) actR.value = calcRR(dir, entry, sl, close);
}
function bindForm() {
@@ -62,9 +70,8 @@
['止盈', data.take_profit], ['平仓价', data.close_price],
['张数', data.lots], ['开仓时间', data.open_time],
['平仓时间', data.close_time], ['持仓时长', data.holding_duration],
- ['初始盈亏', data.initial_pnl], ['实际盈亏', data.actual_pnl],
+ ['初始盈亏比', data.initial_pnl], ['实际盈亏比', data.actual_pnl],
['盈亏金额', data.pnl], ['开仓类型', data.open_type],
- ['预期RR', data.expected_rr], ['实际RR', data.actual_rr],
['离场触发', data.exit_trigger], ['离场补充', data.exit_supplement],
['情绪单', data.is_emotion ? '是' : '否'],
['行为标签', data.behavior_tags], ['备注', data.notes]
diff --git a/templates/base.html b/templates/base.html
index 686da7f..5b1193d 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -8,17 +8,16 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a10;color:#eaeaea;min-height:100vh}
.page-wrap{max-width:1800px;margin:0 auto;min-height:100vh}
- .topbar{background:#12121a;border-bottom:1px solid #242435;padding:0 1.5rem}
- .topbar-inner{display:flex;align-items:center;gap:1.5rem;height:56px}
- .logo{font-size:1.05rem;font-weight:600;background:linear-gradient(90deg,#4cc2ff,#7b42ff);-webkit-background-clip:text;-webkit-text-fill-color:transparent;white-space:nowrap}
- .nav{display:flex;gap:.25rem;flex:1;flex-wrap:wrap}
- .nav a{padding:.5rem 1rem;color:#a9a9c4;text-decoration:none;font-size:.9rem;border-radius:8px;transition:.2s}
- .nav a:hover{color:#fff;background:#1a1a29}
- .nav a.active{color:#4cc2ff;background:#1a1a29}
- .user-bar{font-size:.85rem;color:#888;white-space:nowrap}
+ .site-header{text-align:center;padding:1.5rem 1rem 1.25rem;border-bottom:1px solid #1a1a28;position:relative}
+ .site-title{font-size:1.75rem;font-weight:700;color:#fff;margin-bottom:.55rem;line-height:1.3}
+ .site-badge{display:inline-block;padding:.22rem .85rem;border-radius:999px;border:1px solid #2d6a4f;background:#0d2818;color:#4cd97f;font-size:.75rem;margin-bottom:1.15rem}
+ .site-nav{display:flex;justify-content:center;gap:.45rem;flex-wrap:wrap}
+ .site-nav a{padding:.55rem 1.15rem;border-radius:8px;border:1px solid #2a2a40;background:#161625;color:#e8e8f0;text-decoration:none;font-size:.88rem;transition:.2s;white-space:nowrap}
+ .site-nav a:hover{background:#1e2533;border-color:#3a3a55;color:#fff}
+ .site-nav a.active{background:#2d5aa8;border-color:#3d6ec4;color:#fff}
+ .user-bar{position:absolute;top:1rem;right:1.5rem;font-size:.8rem;color:#888;white-space:nowrap}
.user-bar a{color:#ff6666;text-decoration:none;margin-left:.5rem}
.main{padding:1.5rem}
- .page-title{font-size:1.5rem;margin-bottom:1.5rem;color:#fff}
.flash{padding:1rem;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:1.5rem;text-align:center}
.card{background:#12121a;border-radius:16px;padding:1.5rem;border:1px solid #242435;margin-bottom:1.5rem}
.card h2{font-size:1.15rem;margin-bottom:1rem;color:#c4c4ff;display:flex;align-items:center;gap:.5rem}
@@ -26,6 +25,7 @@
.form-row{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem;align-items:center}
.form-compact{display:flex;flex-direction:column;gap:.5rem;margin-bottom:1rem}
.form-compact .form-line{display:grid;gap:.5rem;align-items:center}
+ .form-compact .line-2{grid-template-columns:repeat(2,1fr)}
.form-compact .line-3{grid-template-columns:repeat(3,1fr)}
.form-compact .line-4{grid-template-columns:repeat(4,1fr)}
.form-compact .line-5{grid-template-columns:repeat(5,1fr)}
@@ -110,26 +110,28 @@
.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%}
+ .site-header{padding:1.25rem .75rem 1rem}
+ .site-title{font-size:1.35rem}
+ .user-bar{position:static;text-align:center;margin-bottom:.75rem}
+ .site-nav{gap:.35rem}
+ .site-nav a{padding:.45rem .75rem;font-size:.82rem}
}
{% block extra_css %}{% endblock %}
-
-
+
{% with msg=get_flashed_messages() %}{% if msg %}{{ msg[0] }}
{% endif %}{% endwith %}
diff --git a/templates/keys.html b/templates/keys.html
index b30addd..4c7df25 100644
--- a/templates/keys.html
+++ b/templates/keys.html
@@ -1,8 +1,6 @@
{% extends "base.html" %}
{% block title %}关键位监控 - 国内期货监控系统{% endblock %}
{% block content %}
-关键位监控
-
新增监控
diff --git a/templates/plans.html b/templates/plans.html
index 483bdf2..ff0f7a3 100644
--- a/templates/plans.html
+++ b/templates/plans.html
@@ -1,11 +1,9 @@
{% extends "base.html" %}
{% block title %}开单计划 - 国内期货监控系统{% endblock %}
{% block content %}
-
开单计划 今日 {{ today }}
-
-
今日计划
+
今日计划 今日 {{ today }}