diff --git a/admin_settings.py b/admin_settings.py new file mode 100644 index 0000000..e66e263 --- /dev/null +++ b/admin_settings.py @@ -0,0 +1,86 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""Web 登录账号:settings 表 + .env 同步。""" +from __future__ import annotations + +import os +import re +from typing import Callable + +from werkzeug.security import check_password_hash, generate_password_hash + +from env_file import update_env_vars + +ADMIN_USERNAME_KEY = "ADMIN_USERNAME" +ADMIN_PASSWORD_KEY = "ADMIN_PASSWORD" + + +def save_admin_credentials( + *, + username: str, + old_password: str, + new_password: str, + new_password2: str, + get_setting: Callable[[str, str], str], + set_setting: Callable[[str, str], None], +) -> tuple[bool, str, dict[str, str]]: + """ + 校验原密码后更新用户名/密码,写入 settings 与 .env。 + 返回 (成功, 提示, env_updates)。 + """ + username = (username or "").strip() + old_password = old_password or "" + new_password = new_password or "" + new_password2 = new_password2 or "" + + if not username: + return False, "用户名不能为空", {} + if len(username) > 64: + return False, "用户名过长(最多 64 字符)", {} + if not re.match(r"^[A-Za-z0-9_.@-]+$", username): + return False, "用户名仅支持字母、数字及 _ . @ -", {} + + admin_hash = get_setting("admin_password_hash") + if not admin_hash or not check_password_hash(admin_hash, old_password): + return False, "原密码错误", {} + + current_username = (get_setting("admin_username") or "").strip() + password_change = bool(new_password or new_password2) + + if password_change: + if not new_password or not new_password2: + return False, "请同时填写新密码与确认密码", {} + if len(new_password) < 6: + return False, "新密码至少 6 位", {} + if new_password != new_password2: + return False, "两次新密码不一致", {} + + username_changed = username != current_username + if not username_changed and not password_change: + return False, "未修改任何内容", {} + + set_setting("admin_username", username) + env_updates: dict[str, str] = {ADMIN_USERNAME_KEY: username} + + if password_change: + set_setting("admin_password_hash", generate_password_hash(new_password)) + env_updates[ADMIN_PASSWORD_KEY] = new_password + + try: + update_env_vars(env_updates) + except OSError as exc: + return False, f"数据库已更新,但写入 .env 失败:{exc}", env_updates + + for key, val in env_updates.items(): + os.environ[key] = val + + parts: list[str] = [] + if username_changed: + parts.append("用户名已更新") + if password_change: + parts.append("密码已更新") + parts.append("已同步至 .env") + return True, ";".join(parts), env_updates diff --git a/app.py b/app.py index b825465..791d47c 100644 --- a/app.py +++ b/app.py @@ -51,6 +51,7 @@ from kline_stream import kline_hub, sse_format from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label from db_conn import connect_db +from admin_settings import save_admin_credentials from db_backup import ( backup_dir, backup_in_progress, @@ -470,6 +471,8 @@ def build_market_quote_payload( symbol: str, market_code: str = "", sina_code: str = "", + *, + prefer_sina: bool = False, ) -> dict: if not market_code or not sina_code: codes = ths_to_codes(symbol) @@ -479,20 +482,21 @@ def build_market_quote_payload( quote_source = "sina" price = None prev_close = None - try: - from vnpy_bridge import ctp_status, ctp_get_tick_detail - from trading_context import get_trading_mode + if not prefer_sina: + try: + from vnpy_bridge import ctp_status, ctp_get_tick_detail + from trading_context import get_trading_mode - mode = get_trading_mode(get_setting) - if ctp_status(mode).get("connected"): - detail = ctp_get_tick_detail(mode, symbol) - if detail.get("price"): - price = detail["price"] - quote_source = "ctp" - if detail.get("pre_close") is not None: - prev_close = detail["pre_close"] - except Exception: - pass + mode = get_trading_mode(get_setting) + if ctp_status(mode).get("connected"): + detail = ctp_get_tick_detail(mode, symbol) + if detail.get("price"): + price = detail["price"] + quote_source = "ctp" + if detail.get("pre_close") is not None: + prev_close = detail["pre_close"] + except Exception: + pass if price is None: price = fetch_price(symbol, market_code, sina_code) name = symbol @@ -715,7 +719,9 @@ def start_background_threads(): threading.Thread( target=lambda: kline_hub.worker_loop( DB_PATH, - build_market_quote_payload, + lambda sym, mc, sc: build_market_quote_payload( + sym, mc, sc, prefer_sina=True, + ), get_mode_fn=lambda: get_trading_mode(get_setting), ), daemon=True, @@ -1553,7 +1559,7 @@ def api_kline(): from trading_context import get_trading_mode data = fetch_market_klines( - symbol, period, DB_PATH, trading_mode=get_trading_mode(get_setting), + symbol, period, DB_PATH, prefer_ctp=False, ) except Exception as exc: app.logger.warning("kline api failed: %s", exc) @@ -1578,19 +1584,18 @@ def api_kline_stream(): return jsonify({"error": "请提供合约代码"}), 400 def generate(): - from trading_context import get_trading_mode - - mode = get_trading_mode(get_setting) sub = kline_hub.subscribe(symbol, period, market_code, sina_code) try: kline_data = fetch_market_klines( - symbol, period, DB_PATH, trading_mode=mode, + symbol, period, DB_PATH, prefer_ctp=False, ) if kline_data.get("bars"): yield sse_format("kline", kline_data) yield sse_format( "quote", - build_market_quote_payload(symbol, market_code, sina_code), + build_market_quote_payload( + symbol, market_code, sina_code, prefer_sina=True, + ), ) while True: try: @@ -1620,7 +1625,9 @@ def api_market_quote(): sina_code = request.args.get("sina_code", "").strip() if not symbol and not market_code: return jsonify({"error": "请提供合约"}), 400 - return jsonify(build_market_quote_payload(symbol, market_code, sina_code)) + return jsonify(build_market_quote_payload( + symbol, market_code, sina_code, prefer_sina=True, + )) @app.route("/contract") @@ -1834,19 +1841,17 @@ def settings(): save_nav_items(set_setting, items) flash("导航显示已保存") elif action == "password": - old_p = request.form.get("old_password", "") - new_p = request.form.get("new_password", "") - new_p2 = request.form.get("new_password2", "") - admin_hash = get_setting("admin_password_hash") - if not check_password_hash(admin_hash, old_p): - flash("原密码错误") - elif len(new_p) < 6: - flash("新密码至少 6 位") - elif new_p != new_p2: - flash("两次新密码不一致") - else: - set_setting("admin_password_hash", generate_password_hash(new_p)) - flash("密码修改成功") + ok, msg, _ = save_admin_credentials( + username=request.form.get("admin_username", ""), + old_password=request.form.get("old_password", ""), + new_password=request.form.get("new_password", ""), + new_password2=request.form.get("new_password2", ""), + get_setting=get_setting, + set_setting=set_setting, + ) + if ok and session.get("logged_in"): + session["username"] = (request.form.get("admin_username") or "").strip() + flash(msg) return redirect(url_for("settings")) webhook = get_setting("wechat_webhook") diff --git a/docs/FEATURES.md b/docs/FEATURES.md index dcf0a24..d4f07a0 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -176,7 +176,7 @@ | CTP 连接 | SimNow / 实盘前置与账号(可覆盖 `.env`) | | 参考资金 | CTP 未连接时用于可开仓筛选与估算 | | 企业微信 Webhook | 计划/关键位推送 | -| 修改密码 | 管理员密码 | +| 登录账号 | 用户名/密码,同步写入 `.env` | | 数据备份与恢复 | 自动/手动备份、下载压缩包、恢复说明 | | 深色/浅色主题 | 页头切换 | diff --git a/env_file.py b/env_file.py new file mode 100644 index 0000000..85ae301 --- /dev/null +++ b/env_file.py @@ -0,0 +1,60 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""读写项目根目录 .env 文件(更新指定键,保留其余行)。""" +from __future__ import annotations + +import os +import re + +ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") +_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=") + + +def env_file_path(path: str | None = None) -> str: + return path or ENV_PATH + + +def _quote_env_value(value: str) -> str: + if value == "": + return '""' + if re.search(r'[\s#"\'\\=]', value): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + return value + + +def update_env_vars(updates: dict[str, str], path: str | None = None) -> None: + """更新或追加 KEY=value,不改动注释与其他配置项。""" + if not updates: + return + env_path = env_file_path(path) + lines: list[str] = [] + if os.path.isfile(env_path): + with open(env_path, encoding="utf-8") as f: + lines = f.read().splitlines() + + seen: set[str] = set() + out: list[str] = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + out.append(line) + continue + m = _KEY_RE.match(stripped) + if m and m.group(1) in updates: + key = m.group(1) + out.append(f"{key}={_quote_env_value(updates[key])}") + seen.add(key) + else: + out.append(line) + + for key, val in updates.items(): + if key not in seen: + out.append(f"{key}={_quote_env_value(val)}") + + with open(env_path, "w", encoding="utf-8", newline="\n") as f: + if out: + f.write("\n".join(out) + "\n") diff --git a/kline_chart.py b/kline_chart.py index 8eb9c89..d0d069d 100644 --- a/kline_chart.py +++ b/kline_chart.py @@ -268,7 +268,7 @@ def fetch_market_klines( force_remote: bool = False, *, trading_mode: Optional[str] = None, - prefer_ctp: bool = True, + prefer_ctp: bool = False, ) -> dict: chart_sym = ths_to_sina_chart_symbol(symbol) p = (period or "15m").lower() @@ -303,11 +303,7 @@ def fetch_market_klines( except Exception as exc: logger.debug("ctp kline fetch failed %s %s: %s", symbol, p, exc) - need_sina = ( - force_remote - or not ctp_bars - or len(ctp_bars) < MIN_CTP_KLINE_BARS - ) + need_sina = force_remote or not prefer_ctp or not ctp_bars or len(ctp_bars) < MIN_CTP_KLINE_BARS if ctp_bars and len(ctp_bars) >= MIN_CTP_KLINE_BARS: bars = ctp_bars @@ -325,10 +321,10 @@ def fetch_market_klines( except Exception as exc: logger.warning("kline cache read failed %s %s: %s", chart_sym, p, exc) - if not bars or len(ctp_bars) < MIN_CTP_KLINE_BARS: + if not bars or len(ctp_bars) < MIN_CTP_KLINE_BARS or not prefer_ctp: remote_bars = fetch_sina_klines(symbol, p) if remote_bars: - if ctp_bars and ctp_connected: + if prefer_ctp and ctp_bars and ctp_connected: bars = _merge_kline_bars(remote_bars, ctp_bars) source = "ctp+remote" else: diff --git a/kline_stream.py b/kline_stream.py index eb92914..8e192dc 100644 --- a/kline_stream.py +++ b/kline_stream.py @@ -137,7 +137,7 @@ class KlineStreamHub: sub.period, db_path, force_remote=True, - trading_mode=get_mode_fn() if get_mode_fn else None, + prefer_ctp=False, ) if kline_data.get("bars"): self.publish(sub, "kline", kline_data) diff --git a/static/js/market.js b/static/js/market.js index 6f42943..653bd7d 100644 --- a/static/js/market.js +++ b/static/js/market.js @@ -20,9 +20,11 @@ var streamActive = false; var reconnectTimer = null; var lastData = null; + var lastRenderedPrepared = null; var lastPrevClose = null; var chartOpts = { prevClose: false, ma: false, gapDay: false }; var followingLatest = true; + var autoFollow = true; var DEFAULT_VISIBLE_BARS = 80; var PERIOD_SECONDS = { @@ -163,6 +165,7 @@ ma55Series = null; prevCloseLine = null; currentChartMode = ''; + lastRenderedPrepared = null; } function buildChart(mode) { @@ -313,20 +316,58 @@ }); } - function renderChart(data, preserveRange) { - if (!chartEl || !window.LightweightCharts) return; - lastData = data; - if (data.prev_close != null) lastPrevClose = data.prev_close; + function shouldPreserveView() { + return !autoFollow || !followingLatest; + } - var isLine = data.chart_type === 'line' || data.period === 'timeshare'; - var mode = isLine ? 'line' : 'candle'; - if (!chart || currentChartMode !== mode) buildChart(mode); - if (!chart) return; + function applyBarUpdate(bar, mode, prepared) { + if (mode === 'line') { + areaSeries.update({ time: bar.time, value: bar.close }); + return; + } + candleSeries.update({ + time: bar.time, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + }); + var up = bar.close >= bar.open; + var c = themeColors(); + volumeSeries.update({ + time: bar.time, + value: bar.volume, + color: up ? c.up : c.down, + }); + if (chartOpts.ma && ma21Series && ma55Series && prepared && prepared.length) { + var ma21 = calcMA(21, prepared); + var ma55 = calcMA(55, prepared); + if (ma21.length) ma21Series.update(ma21[ma21.length - 1]); + if (ma55.length) ma55Series.update(ma55[ma55.length - 1]); + } + } - var prepared = prepareBars(data.bars || [], data.period || currentPeriod); - data.preparedBars = prepared; - if (!prepared.length) return; + function tryIncrementalUpdate(prepared, mode) { + if (!lastRenderedPrepared || !prepared.length) return false; + var prev = lastRenderedPrepared; + var prevLast = prev[prev.length - 1]; + var newLast = prepared[prepared.length - 1]; + if (prepared.length === prev.length && newLast.time === prevLast.time) { + applyBarUpdate(newLast, mode, prepared); + return true; + } + if (prepared.length === prev.length + 1 && prepared[prepared.length - 2].time === prevLast.time) { + applyBarUpdate(newLast, mode, prepared); + if (autoFollow && followingLatest) { + setVisibleRange(prepared, true); + } + return true; + } + return false; + } + + function renderChartFull(prepared, data, mode, preserveRange) { if (mode === 'line') { areaSeries.setData(prepared.map(function (b) { return { time: b.time, value: b.close }; @@ -350,8 +391,33 @@ } applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close); } + if (!shouldPreserveView()) { + setVisibleRange(prepared, !!preserveRange); + } + } - setVisibleRange(prepared, !!preserveRange); + function renderChart(data, options) { + options = options || {}; + if (!chartEl || !window.LightweightCharts) return; + lastData = data; + if (data.prev_close != null) lastPrevClose = data.prev_close; + + var isLine = data.chart_type === 'line' || data.period === 'timeshare'; + var mode = isLine ? 'line' : 'candle'; + if (!chart || currentChartMode !== mode) buildChart(mode); + if (!chart) return; + + var prepared = prepareBars(data.bars || [], data.period || currentPeriod); + data.preparedBars = prepared; + if (!prepared.length) return; + + if (!options.forceFull && shouldPreserveView() && tryIncrementalUpdate(prepared, mode)) { + lastRenderedPrepared = prepared; + return; + } + + renderChartFull(prepared, data, mode, options.preserveRange); + lastRenderedPrepared = prepared; } function periodLabel(key) { @@ -388,8 +454,6 @@ } function klineSourceLabel(src) { - if (src === 'ctp') return 'CTP'; - if (src === 'ctp+remote') return '新浪+CTP'; if (src === 'local') return '本地缓存'; return '新浪'; } @@ -414,9 +478,9 @@ src = ' · ' + klineSourceLabel(lastData.source); } if (isTradingSession()) { - el.textContent = 'TradingView 图表 · 交易中 SSE 推送' + src; + el.textContent = '新浪数据 · 交易中 SSE 推送' + src; } else { - el.textContent = 'TradingView 图表 · 非交易时段低频刷新' + src; + el.textContent = '新浪数据 · 非交易时段低频刷新' + src; } } @@ -495,7 +559,7 @@ var data = JSON.parse(e.data); if (!data.bars || !data.bars.length) return; hideEmptyOverlay(); - renderChart(data, lastData !== null); + renderChart(data, { preserveRange: lastData !== null }); updateQuoteMeta(data); if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close); updateRefreshHint(false); @@ -522,7 +586,7 @@ if (data.count) parts.push('共 ' + data.count + ' 根 · ' + periodLabel(data.period)); if (data.source) parts.push('K线 ' + klineSourceLabel(data.source)); if (data.quote_source) { - parts.push('报价 ' + (data.quote_source === 'ctp' ? 'CTP' : '新浪')); + parts.push('报价 新浪'); } meta.textContent = parts.join(' · '); } @@ -580,6 +644,21 @@ if (zoomReset) zoomReset.addEventListener('click', resetDataZoom); } + function bindAutoButton() { + var btn = document.getElementById('market-auto-btn'); + if (!btn) return; + btn.addEventListener('click', function () { + autoFollow = !autoFollow; + btn.classList.toggle('is-active', autoFollow); + if (autoFollow) { + followingLatest = true; + if (lastData && lastData.preparedBars) { + setVisibleRange(lastData.preparedBars, false); + } + } + }); + } + function bindChartOptions() { var prevCb = document.getElementById('chart-opt-prev-close'); var maCb = document.getElementById('chart-opt-ma'); @@ -597,7 +676,7 @@ chartOpts.ma = maCb.checked; if (lastData) { destroyChart(); - renderChart(lastData, false); + renderChart(lastData, { forceFull: true }); } }); } @@ -605,7 +684,7 @@ gapCb.addEventListener('change', function () { chartOpts.gapDay = gapCb.checked; followingLatest = true; - if (lastData) renderChart(lastData, false); + if (lastData) renderChart(lastData, { forceFull: true }); }); } } @@ -617,13 +696,14 @@ } bindPeriodTabs(); bindZoomButtons(); + bindAutoButton(); bindChartOptions(); document.addEventListener('click', function (e) { if (e.target.closest('[data-theme-pick]') && lastData) { setTimeout(function () { destroyChart(); - renderChart(lastData, false); + renderChart(lastData, { forceFull: true }); }, 80); } }); @@ -640,6 +720,7 @@ input.addEventListener('symbol-selected', function () { lastPrevClose = null; lastData = null; + lastRenderedPrepared = null; destroyChart(); updatePrevCloseDisplay(null); loadKline(true); diff --git a/templates/market.html b/templates/market.html index ca08905..59abf9e 100644 --- a/templates/market.html +++ b/templates/market.html @@ -41,12 +41,16 @@ +
+ + 关闭后可自由拖动查看历史,刷新时只更新最新 K 线,不重置视图 +
请选择合约并点击「查看」
连接中…
-

图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTP;K 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时回退新浪{% endif %}。滚轮缩放、拖拽平移;勾选「间隔日」可压缩夜盘空白。

+

图表引擎:TradingView Lightweight Charts(红跌绿涨)。K 线与报价均使用新浪数据。滚轮缩放、拖拽平移;关闭「自动」后拖动查看历史时,推送更新不会重置画面。