Use Sina-only market K-lines and editable admin login synced to .env.

Market page uses Sina for quotes and bars with an auto-follow toggle and incremental chart updates while panning. Settings lets users change username and password, persisting to the database and .env.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 13:53:12 +08:00
parent 6905373401
commit 382a9a0e14
9 changed files with 324 additions and 75 deletions
+86
View File
@@ -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
+26 -21
View File
@@ -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,6 +482,7 @@ def build_market_quote_payload(
quote_source = "sina"
price = None
prev_close = None
if not prefer_sina:
try:
from vnpy_bridge import ctp_status, ctp_get_tick_detail
from trading_context import get_trading_mode
@@ -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")
+1 -1
View File
@@ -176,7 +176,7 @@
| CTP 连接 | SimNow / 实盘前置与账号(可覆盖 `.env` |
| 参考资金 | CTP 未连接时用于可开仓筛选与估算 |
| 企业微信 Webhook | 计划/关键位推送 |
| 修改密码 | 管理员密码 |
| 登录账号 | 用户名/密码,同步写入 `.env` |
| 数据备份与恢复 | 自动/手动备份、下载压缩包、恢复说明 |
| 深色/浅色主题 | 页头切换 |
+60
View File
@@ -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")
+4 -8
View File
@@ -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:
+1 -1
View File
@@ -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)
+102 -21
View File
@@ -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,9 +391,34 @@
}
applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close);
}
if (!shouldPreserveView()) {
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) {
var tabs = document.querySelectorAll('.period-tab');
@@ -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);
+20 -1
View File
@@ -41,12 +41,16 @@
</div>
<span class="market-refresh-hint text-muted" id="market-refresh-hint"></span>
</div>
<div class="market-chart-auto-row">
<button type="button" class="chart-auto-btn is-active" id="market-auto-btn" title="开启后自动跟随最新 K 线">自动</button>
<span class="hint market-auto-hint">关闭后可自由拖动查看历史,刷新时只更新最新 K 线,不重置视图</span>
</div>
<div class="market-chart-wrap" id="market-chart-wrap">
<div id="market-chart" class="market-chart" aria-label="K线图"></div>
<div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
<div class="market-chart-loading" id="market-chart-loading">连接中…</div>
</div>
<p class="hint">图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTPK 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时回退新浪{% endif %}。滚轮缩放、拖拽平移;勾选「间隔日」可压缩夜盘空白</p>
<p class="hint">图表引擎:TradingView Lightweight Charts(红跌绿涨)。K 线与报价均使用<strong>新浪</strong>数据。滚轮缩放、拖拽平移;关闭「自动」后拖动查看历史时,推送更新不会重置画面</p>
</div>
<style>
@@ -102,6 +106,21 @@
.chart-zoom-btn:hover{border-color:var(--accent);color:var(--accent)}
.chart-zoom-reset{width:auto;padding:0 .65rem;font-size:.75rem}
.market-refresh-hint{font-size:.72rem}
.market-chart-auto-row{
display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;
margin-bottom:.5rem;
}
.chart-auto-btn{
padding:.38rem .85rem;border-radius:999px;
border:1px solid var(--input-border);background:var(--toggle-bg);
color:var(--text-muted);font-size:.78rem;cursor:pointer;width:auto;
}
.chart-auto-btn:hover{border-color:var(--accent);color:var(--accent)}
.chart-auto-btn.is-active{
background:linear-gradient(135deg,var(--accent),var(--accent-2));
border-color:transparent;color:#fff;
}
.market-auto-hint{font-size:.72rem;margin:0}
.market-chart-wrap{
position:relative;border-radius:12px;border:1px solid var(--card-border);
background:var(--card-inner);
+11 -9
View File
@@ -420,28 +420,30 @@
</details>
{% endcall %}
{% call settings_card('password', '修改密码', 'settings-compact-card') %}
{% call settings_card('password', '登录账号', 'settings-compact-card') %}
<form action="{{ url_for('settings') }}" method="post" class="settings-password-form">
<input type="hidden" name="action" value="password">
<div class="field field-full">
<label>当前账号</label>
<input type="text" value="{{ username }}" disabled>
<label>用户名</label>
<input name="admin_username" type="text" value="{{ username }}" required maxlength="64"
pattern="[A-Za-z0-9_.@-]+" autocomplete="username">
</div>
<div class="field">
<div class="field field-full">
<label>原密码</label>
<input name="old_password" type="password" required>
<input name="old_password" type="password" required autocomplete="current-password">
</div>
<div class="field">
<label>新密码</label>
<input name="new_password" type="password" required minlength="6" placeholder="至少 6 位">
<input name="new_password" type="password" minlength="6" placeholder="留空则不修改" autocomplete="new-password">
</div>
<div class="field field-full">
<div class="field">
<label>确认新密码</label>
<input name="new_password2" type="password" required minlength="6">
<input name="new_password2" type="password" minlength="6" placeholder="修改密码时填写" autocomplete="new-password">
</div>
<div class="field-full">
<button type="submit" class="btn-primary">修改密码</button>
<button type="submit" class="btn-primary">保存账号</button>
</div>
<p class="hint" style="margin:.45rem 0 0;font-size:.72rem">保存后写入数据库,并同步至 <code>.env</code><code>ADMIN_USERNAME</code> / <code>ADMIN_PASSWORD</code></p>
</form>
{% endcall %}
</div>