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 %}
-
-
- - -
{{ session.username or '用户' }}退出
-
+
{% 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 }}

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

diff --git a/templates/records.html b/templates/records.html index 21a8880..510ab6b 100644 --- a/templates/records.html +++ b/templates/records.html @@ -1,8 +1,6 @@ {% extends "base.html" %} {% block title %}交易记录与复盘 - 国内期货监控系统{% endblock %} {% block content %} -

交易记录与复盘

-

复盘上传

@@ -30,11 +28,9 @@
-
- - - - +
+ +
自动K线 + @@ -121,7 +117,7 @@ "open_time": r.open_time, "close_time": r.close_time, "holding_duration": r.holding_duration, "initial_pnl": r.initial_pnl, "actual_pnl": r.actual_pnl, "pnl": r.pnl, - "open_type": r.open_type, "expected_rr": r.expected_rr, "actual_rr": r.actual_rr, + "open_type": r.open_type, "exit_trigger": r.exit_trigger, "exit_supplement": r.exit_supplement, "is_emotion": r.is_emotion, "behavior_tags": r.behavior_tags, "notes": r.notes, "screenshot": r.screenshot diff --git a/templates/settings.html b/templates/settings.html index 62bd7a1..7f8688d 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -1,7 +1,6 @@ {% extends "base.html" %} {% block title %}系统设置 - 国内期货监控系统{% endblock %} {% block content %} -

系统设置

行情说明

diff --git a/templates/stats.html b/templates/stats.html index 29b58bf..9d48ad8 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -1,7 +1,6 @@ {% extends "base.html" %} {% block title %}统计分析 - 国内期货监控系统{% endblock %} {% block content %} -

统计分析

总交易
{{ total }}