de6815d481
Co-authored-by: Cursor <cursoragent@cursor.com>
1719 lines
60 KiB
Python
1719 lines
60 KiB
Python
import os
|
||
|
||
from locale_fix import ensure_process_locale
|
||
|
||
ensure_process_locale()
|
||
|
||
import sqlite3
|
||
import time
|
||
import threading
|
||
import requests
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional
|
||
from functools import wraps
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from werkzeug.utils import secure_filename
|
||
|
||
from dotenv import load_dotenv
|
||
from flask import (
|
||
Flask, render_template, request, redirect, url_for,
|
||
flash, session, jsonify, Response, stream_with_context,
|
||
)
|
||
from werkzeug.security import check_password_hash, generate_password_hash
|
||
|
||
from functools import wraps
|
||
|
||
from symbols import search_symbols, ths_to_codes, list_main_contracts_grouped, refresh_main_index
|
||
from contract_specs import calc_position_metrics
|
||
from fee_specs import (
|
||
calc_fee_breakdown,
|
||
calc_round_trip_fee,
|
||
get_fee_multiplier,
|
||
get_fee_source_mode,
|
||
list_all_fee_rates,
|
||
list_fee_rates_for_ui,
|
||
count_fee_rates_by_source,
|
||
load_fee_rates_from_json,
|
||
upsert_fee_rate,
|
||
)
|
||
from fee_sync import sync_fees_from_akshare
|
||
from nav_settings import NAV_TOGGLES, get_nav_items, nav_enabled, save_nav_items
|
||
from contract_profile import get_contract_profile
|
||
from stats_engine import STATS_VIEWS, load_stats_cache, refresh_stats_cache
|
||
from kline_store import ensure_kline_tables
|
||
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 strategy.strategy_db import init_strategy_tables
|
||
from install_trading import install_trading
|
||
from vnpy_bridge import try_init_vnpy
|
||
|
||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||
|
||
app = Flask(__name__)
|
||
app.secret_key = os.getenv("SECRET_KEY", "futures_monitor_default_secret")
|
||
|
||
HOST = os.getenv("HOST", "0.0.0.0")
|
||
PORT = int(os.getenv("PORT", "6600"))
|
||
DEBUG = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
|
||
|
||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db")
|
||
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
|
||
TZ = ZoneInfo("Asia/Shanghai")
|
||
|
||
OPEN_TYPES = ["突破开仓", "回调开仓", "追涨杀跌", "计划内开仓", "震荡摸顶底", "其他"]
|
||
EXIT_TRIGGERS = ["止盈", "止损", "手工平仓", "移动止损", "时间离场", "其他"]
|
||
BEHAVIOR_TAGS = ["怕踏空", "报复开仓", "盈利飘了", "拿不住单", "扛单", "重仓违规"]
|
||
KLINE_PERIODS = ["1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"]
|
||
KLINE_CUTOFFS = ["平仓时间", "开仓时间", "当前时间"]
|
||
|
||
|
||
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 holding_to_minutes(open_time: str, close_time: str) -> int:
|
||
try:
|
||
o = datetime.fromisoformat(open_time.strip().replace(" ", "T"))
|
||
c = datetime.fromisoformat(close_time.strip().replace(" ", "T"))
|
||
secs = int((c - o).total_seconds())
|
||
return max(0, secs // 60)
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def classify_close_result(direction: str, close: float, sl: float, tp: float) -> str:
|
||
"""根据平仓价与止损/止盈距离判断结果。"""
|
||
if close is None:
|
||
return "手动平仓"
|
||
tol = max(abs(close) * 0.002, 1.0)
|
||
if abs(close - tp) <= tol:
|
||
return "止盈"
|
||
if abs(close - sl) <= tol:
|
||
return "止损"
|
||
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
|
||
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()
|
||
conn = get_db()
|
||
conn.execute(
|
||
"UPDATE order_plans SET status='expired' WHERE plan_date < ? AND status IN ('planned', 'active')",
|
||
(today,),
|
||
)
|
||
conn.execute(
|
||
"UPDATE order_plans SET plan_date=date(created_at) WHERE plan_date IS NULL OR plan_date=''"
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def get_db():
|
||
return connect_db()
|
||
|
||
|
||
def get_setting(key: str, default: str = "") -> str:
|
||
conn = get_db()
|
||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||
conn.close()
|
||
return row["value"] if row else default
|
||
|
||
|
||
def set_setting(key: str, value: str):
|
||
conn = get_db()
|
||
conn.execute(
|
||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=?",
|
||
(key, value, value),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def require_nav(key: str):
|
||
"""导航项关闭时拒绝访问对应页面。"""
|
||
def decorator(f):
|
||
@wraps(f)
|
||
def wrapped(*args, **kwargs):
|
||
if not nav_enabled(get_setting, key):
|
||
flash("该页面已在系统设置中关闭")
|
||
return redirect(url_for("positions"))
|
||
return f(*args, **kwargs)
|
||
return wrapped
|
||
return decorator
|
||
|
||
|
||
@app.context_processor
|
||
def inject_globals():
|
||
return {"nav_items": get_nav_items(get_setting)}
|
||
|
||
|
||
def _trading_mode() -> str:
|
||
return (get_setting("trading_mode", "simulation") or "simulation").strip()
|
||
|
||
|
||
def touch_stats_cache():
|
||
try:
|
||
conn = get_db()
|
||
capital = float(get_setting("live_capital", "0") or 0)
|
||
refresh_stats_cache(conn, capital)
|
||
conn.close()
|
||
except Exception as exc:
|
||
app.logger.warning("stats cache refresh failed: %s", exc)
|
||
|
||
|
||
def get_stats_data() -> dict:
|
||
conn = get_db()
|
||
capital = float(get_setting("live_capital", "0") or 0)
|
||
data = load_stats_cache(conn)
|
||
if not data:
|
||
data = refresh_stats_cache(conn, capital)
|
||
conn.close()
|
||
return data
|
||
|
||
|
||
def init_db():
|
||
conn = get_db()
|
||
c = conn.cursor()
|
||
c.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)")
|
||
c.execute('''CREATE TABLE IF NOT EXISTS order_plans
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
symbol TEXT, symbol_name TEXT, direction TEXT,
|
||
zone_upper REAL, zone_lower REAL,
|
||
stop_loss REAL, take_profit REAL,
|
||
status TEXT DEFAULT "planned",
|
||
triggered_at TIMESTAMP,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
c.execute('''CREATE TABLE IF NOT EXISTS key_monitors
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
symbol TEXT, symbol_name TEXT, monitor_type TEXT, direction TEXT,
|
||
upper REAL, lower REAL,
|
||
upper_triggered INTEGER DEFAULT 0,
|
||
lower_triggered INTEGER DEFAULT 0,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
c.execute('''CREATE TABLE IF NOT EXISTS trade_records
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
symbol TEXT, symbol_name TEXT, monitor_type TEXT, direction TEXT,
|
||
trigger_price REAL, stop_loss REAL, take_profit REAL,
|
||
result TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
migrations = [
|
||
"ALTER TABLE key_monitors ADD COLUMN symbol_name TEXT",
|
||
"ALTER TABLE key_monitors ADD COLUMN upper_triggered INTEGER DEFAULT 0",
|
||
"ALTER TABLE key_monitors ADD COLUMN lower_triggered INTEGER DEFAULT 0",
|
||
"ALTER TABLE trade_records ADD COLUMN symbol_name TEXT",
|
||
"ALTER TABLE order_plans ADD COLUMN sina_code TEXT",
|
||
"ALTER TABLE order_plans ADD COLUMN market_code TEXT",
|
||
"ALTER TABLE key_monitors ADD COLUMN market_code TEXT",
|
||
"ALTER TABLE key_monitors ADD COLUMN sina_code TEXT",
|
||
"ALTER TABLE trade_records ADD COLUMN market_code TEXT",
|
||
"ALTER TABLE order_plans ADD COLUMN plan_date TEXT",
|
||
"ALTER TABLE order_plans ADD COLUMN decision_reason 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",
|
||
"ALTER TABLE review_records ADD COLUMN symbol_name TEXT",
|
||
"ALTER TABLE review_records ADD COLUMN market_code TEXT",
|
||
"ALTER TABLE review_records ADD COLUMN sina_code TEXT",
|
||
"ALTER TABLE trade_logs ADD COLUMN fee REAL",
|
||
"ALTER TABLE trade_logs ADD COLUMN pnl_net REAL",
|
||
"ALTER TABLE review_records ADD COLUMN fee REAL",
|
||
"ALTER TABLE review_records ADD COLUMN pnl_net REAL",
|
||
]
|
||
for sql in migrations:
|
||
try:
|
||
c.execute(sql)
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
c.execute('''CREATE TABLE IF NOT EXISTS review_records
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
open_time TEXT, close_time TEXT,
|
||
symbol TEXT, timeframe TEXT,
|
||
pnl REAL,
|
||
open_type TEXT, expected_rr REAL, actual_rr REAL,
|
||
exit_trigger TEXT, exit_supplement TEXT,
|
||
watch_after_breakeven TEXT, new_position_while_occupied TEXT,
|
||
screenshot TEXT,
|
||
auto_kline INTEGER DEFAULT 0,
|
||
kline_period1 TEXT, kline_period2 TEXT,
|
||
kline_count INTEGER, kline_cutoff TEXT,
|
||
behavior_tags TEXT, notes TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
c.execute('''CREATE TABLE IF NOT EXISTS position_monitors
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
symbol TEXT, symbol_name TEXT, market_code TEXT, sina_code TEXT,
|
||
direction TEXT, lots REAL, entry_price REAL,
|
||
stop_loss REAL, take_profit REAL, open_time TEXT,
|
||
status TEXT DEFAULT 'active',
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
c.execute('''CREATE TABLE IF NOT EXISTS trade_logs
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
symbol TEXT, symbol_name TEXT, market_code TEXT, sina_code TEXT,
|
||
monitor_type TEXT, direction TEXT,
|
||
entry_price REAL, stop_loss REAL, take_profit REAL, close_price REAL,
|
||
lots REAL, margin REAL, holding_minutes INTEGER,
|
||
open_time TEXT, close_time TEXT,
|
||
pnl REAL, result TEXT,
|
||
verified INTEGER DEFAULT 0,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
c.execute('''CREATE TABLE IF NOT EXISTS fee_rates
|
||
(product TEXT PRIMARY KEY,
|
||
exchange TEXT,
|
||
mult INTEGER,
|
||
open_fixed REAL DEFAULT 0,
|
||
open_ratio REAL DEFAULT 0,
|
||
close_yesterday_fixed REAL DEFAULT 0,
|
||
close_yesterday_ratio REAL DEFAULT 0,
|
||
close_today_fixed REAL DEFAULT 0,
|
||
close_today_ratio REAL DEFAULT 0,
|
||
updated_at TEXT)''')
|
||
c.execute('''CREATE TABLE IF NOT EXISTS stats_cache
|
||
(key TEXT PRIMARY KEY,
|
||
data_json TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL)''')
|
||
for sql in (
|
||
"ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'",
|
||
):
|
||
try:
|
||
c.execute(sql)
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
ensure_kline_tables(conn)
|
||
init_strategy_tables(conn)
|
||
from risk.account_risk_lib import ensure_account_risk_schema
|
||
from recommend_store import ensure_recommend_tables
|
||
ensure_account_risk_schema(conn)
|
||
ensure_recommend_tables(conn)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
sync_admin_from_env()
|
||
|
||
if not get_setting("wechat_webhook") and os.getenv("WECHAT_WEBHOOK"):
|
||
set_setting("wechat_webhook", os.getenv("WECHAT_WEBHOOK"))
|
||
|
||
if not get_setting("ths_refresh_token") and os.getenv("THS_REFRESH_TOKEN"):
|
||
set_setting("ths_refresh_token", os.getenv("THS_REFRESH_TOKEN"))
|
||
|
||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||
expire_old_plans()
|
||
|
||
if not get_setting("fee_multiplier"):
|
||
set_setting("fee_multiplier", "2")
|
||
if not get_setting("trading_mode"):
|
||
set_setting("trading_mode", "simulation")
|
||
if not get_setting("position_sizing_mode"):
|
||
set_setting("position_sizing_mode", "risk")
|
||
if not get_setting("risk_percent"):
|
||
set_setting("risk_percent", "1")
|
||
if not get_setting("fee_source_mode"):
|
||
set_setting("fee_source_mode", "ctp")
|
||
conn = get_db()
|
||
fee_cnt = conn.execute("SELECT COUNT(*) FROM fee_rates").fetchone()[0]
|
||
conn.close()
|
||
if fee_cnt == 0:
|
||
load_fee_rates_from_json()
|
||
|
||
|
||
def sync_admin_from_env():
|
||
"""
|
||
从 .env 同步管理员账号。
|
||
- 首次建库:自动写入 ADMIN_USERNAME / ADMIN_PASSWORD
|
||
- 已建库后改 .env:需设 ADMIN_SYNC_FROM_ENV=true 并重启服务
|
||
"""
|
||
sync = os.getenv("ADMIN_SYNC_FROM_ENV", "false").lower() in ("1", "true", "yes")
|
||
env_username = os.getenv("ADMIN_USERNAME", "").strip()
|
||
env_password = os.getenv("ADMIN_PASSWORD", "").strip()
|
||
placeholder_passwords = {"", "change-me-on-first-login", "admin123"}
|
||
|
||
if not get_setting("admin_username"):
|
||
username = env_username or "admin"
|
||
password = env_password if env_password not in placeholder_passwords else "admin123"
|
||
set_setting("admin_username", username)
|
||
set_setting("admin_password_hash", generate_password_hash(password))
|
||
return
|
||
|
||
if not sync:
|
||
return
|
||
|
||
if env_username:
|
||
set_setting("admin_username", env_username)
|
||
if env_password and env_password not in placeholder_passwords:
|
||
set_setting("admin_password_hash", generate_password_hash(env_password))
|
||
|
||
|
||
init_db()
|
||
|
||
|
||
def sync_ths_token():
|
||
set_ths_refresh_token(get_setting("ths_refresh_token"))
|
||
|
||
|
||
sync_ths_token()
|
||
|
||
|
||
def build_market_quote_payload(
|
||
symbol: str,
|
||
market_code: str = "",
|
||
sina_code: str = "",
|
||
) -> dict:
|
||
if not market_code or not sina_code:
|
||
codes = ths_to_codes(symbol)
|
||
if codes:
|
||
market_code = codes.get("market_code", "") or market_code
|
||
sina_code = codes.get("sina_code", "") or sina_code
|
||
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
|
||
|
||
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
|
||
codes = ths_to_codes(symbol)
|
||
if codes:
|
||
name = codes.get("name", symbol)
|
||
if prev_close is None and sina_code:
|
||
from market import fetch_raw_for_volume
|
||
raw = fetch_raw_for_volume(sina_code)
|
||
if raw and raw.get("prev_close") is not None:
|
||
prev_close = raw["prev_close"]
|
||
return {
|
||
"symbol": symbol,
|
||
"name": name,
|
||
"price": price,
|
||
"prev_close": prev_close,
|
||
"quote_source": quote_source,
|
||
}
|
||
|
||
|
||
# —————————————— 推送 ——————————————
|
||
|
||
def send_wechat_msg(content: str):
|
||
webhook = get_setting("wechat_webhook")
|
||
if not webhook:
|
||
return
|
||
full = f"【国内期货】\n{content}"
|
||
data = {"msgtype": "text", "text": {"content": full}}
|
||
try:
|
||
requests.post(webhook, json=data, timeout=10)
|
||
except Exception:
|
||
pass
|
||
|
||
# —————————————— 行情 ——————————————
|
||
|
||
def resolve_market_codes(ths_code: str, market_code: str = "", sina_code: str = "") -> tuple[str, str]:
|
||
"""返回 (market_code, sina_code) 用于行情拉取。"""
|
||
if market_code:
|
||
return market_code, sina_code
|
||
if sina_code and "." in sina_code:
|
||
return sina_code, ""
|
||
codes = ths_to_codes(ths_code)
|
||
if codes:
|
||
return codes["market_code"], codes["sina_code"]
|
||
if ths_code.startswith("nf_") or ths_code.startswith("CFF_RE_"):
|
||
return ths_code, ths_code
|
||
return "", sina_code or ""
|
||
|
||
|
||
def fetch_price(ths_code: str, market_code: str = "", sina_code: str = "") -> Optional[float]:
|
||
sym = (ths_code or "").strip()
|
||
if sym:
|
||
try:
|
||
from vnpy_bridge import ctp_status, ctp_get_tick_price
|
||
from trading_context import get_trading_mode
|
||
|
||
mode = get_trading_mode(get_setting)
|
||
if ctp_status(mode).get("connected"):
|
||
p = ctp_get_tick_price(mode, sym)
|
||
if p and p > 0:
|
||
return p
|
||
except Exception:
|
||
pass
|
||
mc, sc = resolve_market_codes(sym, market_code, sina_code)
|
||
if not mc and not sc:
|
||
return None
|
||
return market_get_price(mc, sc)
|
||
|
||
# —————————————— 监控逻辑 ——————————————
|
||
|
||
def check_order_plans():
|
||
expire_old_plans()
|
||
today = today_str()
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT * FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active')",
|
||
(today,),
|
||
).fetchall()
|
||
|
||
for r in rows:
|
||
sym = r["symbol"]
|
||
sina = r["sina_code"] if "sina_code" in r.keys() else ""
|
||
market = r["market_code"] if "market_code" in r.keys() else ""
|
||
p = fetch_price(sym, market, sina)
|
||
if not p:
|
||
continue
|
||
|
||
direction = r["direction"]
|
||
zone_upper = r["zone_upper"]
|
||
zone_lower = r["zone_lower"]
|
||
stop_loss = r["stop_loss"]
|
||
take_profit = r["take_profit"]
|
||
status = r["status"]
|
||
pid = r["id"]
|
||
name = r["symbol_name"] or sym
|
||
reason = r["decision_reason"] if "decision_reason" in r.keys() and r["decision_reason"] else "—"
|
||
|
||
# 计划状态:价格进入决策区间则激活并通知
|
||
if status == "planned":
|
||
in_zone = zone_lower <= p <= zone_upper
|
||
if in_zone:
|
||
msg = (
|
||
f"【开单计划触发】{name} ({sym})\n"
|
||
f"方向:{'做多' if direction == 'long' else '做空'}\n"
|
||
f"决策区间:{zone_lower} ~ {zone_upper}\n"
|
||
f"决策理由:{reason}\n"
|
||
f"当前价:{p}\n"
|
||
f"止损:{stop_loss} 止盈:{take_profit}"
|
||
)
|
||
send_wechat_msg(msg)
|
||
conn.execute(
|
||
"UPDATE order_plans SET status='active', triggered_at=? WHERE id=?",
|
||
(datetime.now().isoformat(), pid),
|
||
)
|
||
status = "active"
|
||
|
||
# 激活状态:监控止盈止损
|
||
if status == "active":
|
||
res = None
|
||
if direction == "long":
|
||
if p >= take_profit:
|
||
res = "止盈"
|
||
elif p <= stop_loss:
|
||
res = "止损"
|
||
elif direction == "short":
|
||
if p <= take_profit:
|
||
res = "止盈"
|
||
elif p >= stop_loss:
|
||
res = "止损"
|
||
|
||
if res:
|
||
msg = (
|
||
f"[{'做多' if direction == 'long' else '做空'}] {name} 已{res}\n"
|
||
f"决策区间:{zone_lower} ~ {zone_upper}\n"
|
||
f"止损:{stop_loss} 止盈:{take_profit}\n"
|
||
f"当前价:{p}"
|
||
)
|
||
send_wechat_msg(msg)
|
||
conn.execute(
|
||
"""INSERT INTO trade_records
|
||
(symbol, symbol_name, monitor_type, direction,
|
||
trigger_price, stop_loss, take_profit, result)
|
||
VALUES (?,?,?,?,?,?,?,?)""",
|
||
(sym, name, "开单计划", direction, p, stop_loss, take_profit, res),
|
||
)
|
||
conn.execute(
|
||
"UPDATE order_plans SET status='closed' WHERE id=?", (pid,)
|
||
)
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def check_key_monitors():
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT * FROM key_monitors WHERE status='active' OR status IS NULL"
|
||
).fetchall()
|
||
|
||
for r in rows:
|
||
sym = r["symbol"]
|
||
typ = r["monitor_type"]
|
||
up = r["upper"]
|
||
low = r["lower"]
|
||
up_trig = r["upper_triggered"]
|
||
low_trig = r["lower_triggered"]
|
||
name = r["symbol_name"] or sym
|
||
pid = r["id"]
|
||
sina = r["sina_code"] if "sina_code" in r.keys() else ""
|
||
market = r["market_code"] if "market_code" in r.keys() else ""
|
||
|
||
p = fetch_price(sym, market, sina)
|
||
if not p:
|
||
continue
|
||
|
||
if typ in ("箱体突破", "收敛突破"):
|
||
if p > up and not up_trig:
|
||
send_wechat_msg(f"{name} 突破{typ}上沿 {up}\n当前价:{p}")
|
||
conn.execute(
|
||
"UPDATE key_monitors SET upper_triggered=1 WHERE id=?", (pid,)
|
||
)
|
||
if p < low and not low_trig:
|
||
send_wechat_msg(f"{name} 跌破{typ}下沿 {low}\n当前价:{p}")
|
||
conn.execute(
|
||
"UPDATE key_monitors SET lower_triggered=1 WHERE id=?", (pid,)
|
||
)
|
||
elif typ == "关键阻力位" and p > up and not up_trig:
|
||
send_wechat_msg(f"{name} 突破阻力位 {up}\n当前价:{p}")
|
||
conn.execute(
|
||
"UPDATE key_monitors SET upper_triggered=1 WHERE id=?", (pid,)
|
||
)
|
||
elif typ == "关键支撑位" and p < low and not low_trig:
|
||
send_wechat_msg(f"{name} 跌破支撑位 {low}\n当前价:{p}")
|
||
conn.execute(
|
||
"UPDATE key_monitors SET lower_triggered=1 WHERE id=?", (pid,)
|
||
)
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def background_task():
|
||
while True:
|
||
try:
|
||
expire_old_plans()
|
||
check_key_monitors()
|
||
check_order_plans()
|
||
fn = getattr(app, "_check_trend_plans", None)
|
||
if fn:
|
||
fn(app)
|
||
except Exception:
|
||
pass
|
||
time.sleep(3)
|
||
|
||
|
||
def start_background_threads():
|
||
from trading_context import get_trading_mode
|
||
|
||
threading.Thread(target=background_task, daemon=True).start()
|
||
threading.Thread(
|
||
target=lambda: kline_hub.worker_loop(
|
||
DB_PATH,
|
||
build_market_quote_payload,
|
||
get_mode_fn=lambda: get_trading_mode(get_setting),
|
||
),
|
||
daemon=True,
|
||
).start()
|
||
threading.Thread(target=refresh_main_index, daemon=True).start()
|
||
|
||
|
||
# —————————————— 登录 ——————————————
|
||
|
||
def login_required(f):
|
||
@wraps(f)
|
||
def wrap(*args, **kwargs):
|
||
if not session.get("logged_in"):
|
||
return redirect(url_for("login"))
|
||
return f(*args, **kwargs)
|
||
return wrap
|
||
|
||
|
||
@app.route("/")
|
||
def index():
|
||
if session.get("logged_in"):
|
||
return redirect(url_for("plans"))
|
||
return redirect(url_for("login"))
|
||
|
||
|
||
@app.route("/manifest.webmanifest")
|
||
def web_manifest():
|
||
response = app.send_static_file("manifest.json")
|
||
response.mimetype = "application/manifest+json"
|
||
return response
|
||
|
||
|
||
@app.route("/sw.js")
|
||
def service_worker():
|
||
response = app.send_static_file("sw.js")
|
||
response.headers["Cache-Control"] = "no-cache"
|
||
response.headers["Service-Worker-Allowed"] = "/"
|
||
return response
|
||
|
||
|
||
@app.route("/login", methods=["GET", "POST"])
|
||
def login():
|
||
if request.method == "POST":
|
||
u = request.form.get("username", "").strip()
|
||
p = request.form.get("password", "")
|
||
admin_u = get_setting("admin_username")
|
||
admin_hash = get_setting("admin_password_hash")
|
||
if u == admin_u and check_password_hash(admin_hash, p):
|
||
session["logged_in"] = True
|
||
session["username"] = u
|
||
return redirect(url_for("plans"))
|
||
flash("账号或密码错误")
|
||
return render_template("login.html")
|
||
|
||
|
||
@app.route("/logout")
|
||
def logout():
|
||
session.clear()
|
||
return redirect(url_for("login"))
|
||
|
||
# —————————————— API ——————————————
|
||
|
||
@app.route("/api/symbols/search")
|
||
@login_required
|
||
def api_symbol_search():
|
||
q = request.args.get("q", "")
|
||
return jsonify(search_symbols(q))
|
||
|
||
|
||
@app.route("/api/symbols/mains")
|
||
@login_required
|
||
def api_symbols_mains():
|
||
return jsonify(list_main_contracts_grouped())
|
||
|
||
|
||
@app.route("/api/key_prices")
|
||
@login_required
|
||
def api_key_prices():
|
||
"""关键位监控列表:批量现价与距上/下沿距离。"""
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT id, symbol, market_code, sina_code, upper, lower "
|
||
"FROM key_monitors WHERE status='active' OR status IS NULL"
|
||
).fetchall()
|
||
conn.close()
|
||
out = []
|
||
for r in rows:
|
||
sym = r["symbol"]
|
||
market = r["market_code"] or ""
|
||
sina = r["sina_code"] or ""
|
||
upper = float(r["upper"])
|
||
lower = float(r["lower"])
|
||
price = fetch_price(sym, market, sina)
|
||
dist_upper = None
|
||
dist_lower = None
|
||
if price is not None:
|
||
dist_upper = round(upper - price, 2)
|
||
dist_lower = round(price - lower, 2)
|
||
out.append({
|
||
"id": r["id"],
|
||
"price": price,
|
||
"dist_upper": dist_upper,
|
||
"dist_lower": dist_lower,
|
||
})
|
||
return jsonify(out)
|
||
|
||
|
||
@app.route("/api/plan_prices")
|
||
@login_required
|
||
def api_plan_prices():
|
||
"""今日计划:批量现价与距决策区间上/下沿距离。"""
|
||
today = today_str()
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT id, symbol, market_code, sina_code, zone_upper, zone_lower "
|
||
"FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active')",
|
||
(today,),
|
||
).fetchall()
|
||
conn.close()
|
||
out = []
|
||
for r in rows:
|
||
sym = r["symbol"]
|
||
market = r["market_code"] or ""
|
||
sina = r["sina_code"] or ""
|
||
upper = float(r["zone_upper"])
|
||
lower = float(r["zone_lower"])
|
||
price = fetch_price(sym, market, sina)
|
||
dist_upper = None
|
||
dist_lower = None
|
||
in_zone = False
|
||
if price is not None:
|
||
dist_upper = round(upper - price, 2)
|
||
dist_lower = round(price - lower, 2)
|
||
in_zone = lower <= price <= upper
|
||
out.append({
|
||
"id": r["id"],
|
||
"price": price,
|
||
"dist_upper": dist_upper,
|
||
"dist_lower": dist_lower,
|
||
"in_zone": in_zone,
|
||
})
|
||
return jsonify(out)
|
||
|
||
|
||
@app.route("/api/position_live")
|
||
@login_required
|
||
def api_position_live():
|
||
capital = float(get_setting("live_capital", "0") or 0)
|
||
now_iso = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT * FROM position_monitors WHERE status='active' ORDER BY id DESC"
|
||
).fetchall()
|
||
conn.close()
|
||
out = []
|
||
for r in rows:
|
||
sym = r["symbol"]
|
||
market = r["market_code"] or ""
|
||
sina = r["sina_code"] or ""
|
||
direction = r["direction"]
|
||
entry = float(r["entry_price"])
|
||
sl = float(r["stop_loss"])
|
||
tp = float(r["take_profit"])
|
||
lots = float(r["lots"] or 1)
|
||
mark = fetch_price(sym, market, sina)
|
||
metrics = calc_position_metrics(
|
||
direction, entry, sl, tp, lots, mark, capital, sym,
|
||
)
|
||
holding = calc_holding_duration(r["open_time"] or "", now_iso)
|
||
close_est = mark if mark is not None else entry
|
||
fee_info = calc_fee_breakdown(
|
||
sym, entry, close_est, lots, r["open_time"] or "", now_iso,
|
||
trading_mode=_trading_mode(),
|
||
)
|
||
est_net = None
|
||
if metrics.get("float_pnl") is not None:
|
||
est_net = round(metrics["float_pnl"] - fee_info["total_fee"], 2)
|
||
out.append({
|
||
"id": r["id"],
|
||
"symbol": r["symbol_name"] or sym,
|
||
"symbol_code": sym,
|
||
"direction": "做多" if direction == "long" else "做空",
|
||
"lots": lots,
|
||
"entry_price": entry,
|
||
"stop_loss": sl,
|
||
"take_profit": tp,
|
||
"open_time": r["open_time"],
|
||
"mark_price": mark,
|
||
"holding_duration": holding,
|
||
"est_fee": fee_info["total_fee"],
|
||
"est_fee_open": fee_info["open_fee"],
|
||
"est_fee_close": fee_info["close_fee"],
|
||
"est_fee_close_type": fee_info["close_type"],
|
||
"est_pnl_net": est_net,
|
||
**metrics,
|
||
})
|
||
return jsonify(out)
|
||
|
||
|
||
@login_required
|
||
def index():
|
||
if nav_enabled(get_setting, "plans"):
|
||
return redirect(url_for("plans"))
|
||
return redirect(url_for("positions"))
|
||
|
||
|
||
@app.route("/plans")
|
||
@login_required
|
||
@require_nav("plans")
|
||
def plans():
|
||
today = today_str()
|
||
start = request.args.get("start", "")
|
||
end = request.args.get("end", "")
|
||
|
||
conn = get_db()
|
||
plan_list = conn.execute(
|
||
"SELECT * FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active') ORDER BY id DESC",
|
||
(today,),
|
||
).fetchall()
|
||
|
||
sql = "SELECT * FROM order_plans WHERE plan_date < ? OR status IN ('closed', 'expired')"
|
||
params: list = [today]
|
||
if start:
|
||
sql += " AND plan_date >= ?"
|
||
params.append(start)
|
||
if end:
|
||
sql += " AND plan_date <= ?"
|
||
params.append(end)
|
||
sql += " ORDER BY plan_date DESC, id DESC LIMIT 200"
|
||
history = conn.execute(sql, params).fetchall()
|
||
conn.close()
|
||
return render_template(
|
||
"plans.html",
|
||
plans=plan_list,
|
||
history=history,
|
||
today=today,
|
||
start=start,
|
||
end=end,
|
||
)
|
||
|
||
|
||
@app.route("/add_plan", methods=["POST"])
|
||
@login_required
|
||
def add_plan():
|
||
d = request.form
|
||
direction = d.get("direction")
|
||
symbol = d.get("symbol", "").strip()
|
||
symbol_name = d.get("symbol_name", "").strip()
|
||
market_code = d.get("market_code", "").strip()
|
||
sina_code = d.get("sina_code", "").strip()
|
||
if not direction:
|
||
flash("请选择多空方向")
|
||
return redirect(url_for("plans"))
|
||
if not symbol or not market_code:
|
||
flash("请从下拉列表选择品种(同花顺合约代码)")
|
||
return redirect(url_for("plans"))
|
||
conn = get_db()
|
||
conn.execute(
|
||
"""INSERT INTO order_plans
|
||
(symbol, symbol_name, market_code, sina_code, direction,
|
||
zone_upper, zone_lower, stop_loss, take_profit, plan_date, decision_reason)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
symbol, symbol_name, market_code, sina_code, direction,
|
||
float(d["zone_upper"]), float(d["zone_lower"]),
|
||
float(d["stop_loss"]), float(d["take_profit"]),
|
||
today_str(),
|
||
d.get("decision_reason", "").strip(),
|
||
),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
flash("开单计划已添加")
|
||
return redirect(url_for("plans"))
|
||
|
||
|
||
@app.route("/del_plan/<int:pid>")
|
||
@login_required
|
||
def del_plan(pid):
|
||
conn = get_db()
|
||
conn.execute("DELETE FROM order_plans WHERE id=?", (pid,))
|
||
conn.commit()
|
||
conn.close()
|
||
flash("已删除")
|
||
return redirect(url_for("plans"))
|
||
|
||
|
||
@app.route("/keys")
|
||
@login_required
|
||
def keys():
|
||
conn = get_db()
|
||
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, history=history)
|
||
|
||
|
||
|
||
@app.route("/add_key", methods=["POST"])
|
||
@login_required
|
||
def add_key():
|
||
d = request.form
|
||
direction = d.get("direction")
|
||
symbol = d.get("symbol", "").strip()
|
||
symbol_name = d.get("symbol_name", "").strip()
|
||
market_code = d.get("market_code", "").strip()
|
||
sina_code = d.get("sina_code", "").strip()
|
||
if not direction:
|
||
flash("请选择多空方向")
|
||
return redirect(url_for("keys"))
|
||
if not symbol or not market_code:
|
||
flash("请从下拉列表选择品种(同花顺合约代码)")
|
||
return redirect(url_for("keys"))
|
||
conn = get_db()
|
||
conn.execute(
|
||
"""INSERT INTO key_monitors
|
||
(symbol, symbol_name, market_code, sina_code, monitor_type, direction, upper, lower)
|
||
VALUES (?,?,?,?,?,?,?,?)""",
|
||
(symbol, symbol_name, market_code, sina_code, d["type"], direction, float(d["upper"]), float(d["lower"])),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
flash("关键位监控已添加")
|
||
return redirect(url_for("keys"))
|
||
|
||
|
||
@app.route("/add_position", methods=["POST"])
|
||
@login_required
|
||
def add_position():
|
||
flash("持仓由策略交易或 CTP 自动同步,无需手工录入")
|
||
return redirect(url_for("positions"))
|
||
|
||
|
||
@app.route("/del_position/<int:pid>")
|
||
@login_required
|
||
def del_position(pid):
|
||
return close_position(pid)
|
||
|
||
|
||
@app.route("/close_position/<int:pid>", methods=["POST"])
|
||
@login_required
|
||
def close_position(pid):
|
||
conn = get_db()
|
||
row = conn.execute("SELECT * FROM position_monitors WHERE id=?", (pid,)).fetchone()
|
||
if not row:
|
||
conn.close()
|
||
flash("持仓不存在")
|
||
return redirect(url_for("positions"))
|
||
sym = row["symbol"]
|
||
market = row["market_code"] or ""
|
||
sina = row["sina_code"] or ""
|
||
direction = row["direction"]
|
||
entry = float(row["entry_price"])
|
||
sl = float(row["stop_loss"])
|
||
tp = float(row["take_profit"])
|
||
lots = float(row["lots"] or 1)
|
||
open_time = row["open_time"] or ""
|
||
close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
|
||
close_price = fetch_price(sym, market, sina)
|
||
if close_price is None:
|
||
conn.close()
|
||
flash("无法获取现价,平仓失败")
|
||
return redirect(url_for("positions"))
|
||
capital = float(get_setting("live_capital", "0") or 0)
|
||
metrics = calc_position_metrics(direction, entry, sl, tp, lots, close_price, capital, sym)
|
||
pnl = metrics.get("float_pnl") or 0.0
|
||
fee = calc_round_trip_fee(sym, entry, close_price, lots, open_time, close_time, trading_mode=_trading_mode())
|
||
pnl_net = round(pnl - fee, 2)
|
||
result = classify_close_result(direction, close_price, sl, tp)
|
||
minutes = holding_to_minutes(open_time, close_time)
|
||
conn.execute(
|
||
"""INSERT INTO trade_logs
|
||
(symbol, symbol_name, market_code, sina_code, monitor_type, direction,
|
||
entry_price, stop_loss, take_profit, close_price, lots, margin,
|
||
holding_minutes, open_time, close_time, pnl, fee, pnl_net, result)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
sym, row["symbol_name"], market, sina, "持仓监控", direction,
|
||
entry, sl, tp, close_price, lots, metrics["margin"],
|
||
minutes, open_time, close_time, pnl, fee, pnl_net, result,
|
||
),
|
||
)
|
||
conn.execute("DELETE FROM position_monitors WHERE id=?", (pid,))
|
||
conn.commit()
|
||
conn.close()
|
||
touch_stats_cache()
|
||
flash(f"已平仓,盈亏 {pnl:.2f} 元(扣费后 {pnl_net:.2f} 元),已记入交易记录")
|
||
return redirect(url_for("positions"))
|
||
|
||
|
||
@app.route("/trades")
|
||
@login_required
|
||
def trades():
|
||
return redirect(url_for("records"))
|
||
|
||
|
||
@app.route("/update_trade/<int:tid>", methods=["POST"])
|
||
@login_required
|
||
def update_trade(tid):
|
||
d = request.form
|
||
conn = get_db()
|
||
conn.execute(
|
||
"""UPDATE trade_logs SET
|
||
symbol_name=?, monitor_type=?, direction=?,
|
||
entry_price=?, stop_loss=?, take_profit=?, close_price=?,
|
||
lots=?, margin=?, holding_minutes=?, open_time=?, close_time=?,
|
||
pnl=?, result=?, verified=1
|
||
WHERE id=?""",
|
||
(
|
||
d.get("symbol_name", "").strip(),
|
||
d.get("monitor_type", "").strip(),
|
||
d.get("direction", "").strip(),
|
||
float(d.get("entry_price") or 0),
|
||
float(d.get("stop_loss") or 0),
|
||
float(d.get("take_profit") or 0),
|
||
float(d.get("close_price") or 0),
|
||
float(d.get("lots") or 0),
|
||
float(d.get("margin") or 0),
|
||
int(d.get("holding_minutes") or 0),
|
||
d.get("open_time", "").strip(),
|
||
d.get("close_time", "").strip(),
|
||
float(d.get("pnl") or 0),
|
||
d.get("result", "").strip(),
|
||
tid,
|
||
),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
touch_stats_cache()
|
||
flash("交易记录已核对保存")
|
||
return redirect(url_for("records"))
|
||
|
||
|
||
@app.route("/del_trade/<int:tid>")
|
||
@login_required
|
||
def del_trade(tid):
|
||
conn = get_db()
|
||
conn.execute("DELETE FROM trade_logs WHERE id=?", (tid,))
|
||
conn.commit()
|
||
conn.close()
|
||
touch_stats_cache()
|
||
flash("已删除")
|
||
return redirect(url_for("records"))
|
||
|
||
|
||
@app.route("/fill_review/<int:tid>")
|
||
@login_required
|
||
def fill_review_from_trade(tid):
|
||
conn = get_db()
|
||
row = conn.execute("SELECT * FROM trade_logs WHERE id=?", (tid,)).fetchone()
|
||
conn.close()
|
||
if not row:
|
||
flash("记录不存在")
|
||
return redirect(url_for("records"))
|
||
q = {
|
||
"symbol": row["symbol"],
|
||
"symbol_name": row["symbol_name"] or row["symbol"],
|
||
"market_code": row["market_code"] or "",
|
||
"sina_code": row["sina_code"] or "",
|
||
"direction": row["direction"],
|
||
"entry_price": row["entry_price"],
|
||
"stop_loss": row["stop_loss"],
|
||
"take_profit": row["take_profit"],
|
||
"close_price": row["close_price"],
|
||
"lots": row["lots"],
|
||
"open_time": row["open_time"],
|
||
"close_time": row["close_time"],
|
||
"pnl": row["pnl"],
|
||
}
|
||
params = {k: v for k, v in q.items() if v is not None}
|
||
return redirect(url_for("records", **params) + "#review-panel")
|
||
|
||
|
||
@app.route("/del_key/<int:pid>")
|
||
@login_required
|
||
def del_key(pid):
|
||
conn = get_db()
|
||
conn.execute(
|
||
"UPDATE key_monitors SET status='archived', archived_at=? WHERE id=?",
|
||
(datetime.now(TZ).isoformat(), pid),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
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"
|
||
params: list = []
|
||
if start:
|
||
sql += " AND date(close_time) >= ?"
|
||
params.append(start)
|
||
if end:
|
||
sql += " AND date(close_time) <= ?"
|
||
params.append(end)
|
||
sql += " ORDER BY id DESC LIMIT 200"
|
||
review_list = conn.execute(sql, params).fetchall()
|
||
|
||
auto_list = conn.execute(
|
||
"SELECT * FROM trade_records ORDER BY id DESC LIMIT 30"
|
||
).fetchall()
|
||
trade_list = conn.execute(
|
||
"SELECT * FROM trade_logs ORDER BY id DESC LIMIT 500"
|
||
).fetchall()
|
||
conn.close()
|
||
|
||
trade_prefill_keys = (
|
||
"symbol", "symbol_name", "market_code", "sina_code", "direction",
|
||
"entry_price", "stop_loss", "take_profit", "close_price",
|
||
"lots", "open_time", "close_time", "pnl",
|
||
)
|
||
prefill = {k: request.args.get(k) for k in trade_prefill_keys if request.args.get(k)}
|
||
|
||
return render_template(
|
||
"records.html",
|
||
reviews=review_list,
|
||
trades=trade_list,
|
||
auto_records=auto_list,
|
||
preset=preset,
|
||
start=start,
|
||
end=end,
|
||
prefill=prefill,
|
||
open_types=OPEN_TYPES,
|
||
exit_triggers=EXIT_TRIGGERS,
|
||
behavior_tags=BEHAVIOR_TAGS,
|
||
kline_periods=KLINE_PERIODS,
|
||
kline_cutoffs=KLINE_CUTOFFS,
|
||
)
|
||
|
||
|
||
@app.route("/add_review", methods=["POST"])
|
||
@login_required
|
||
def add_review():
|
||
d = request.form
|
||
open_type = d.get("open_type", "").strip()
|
||
exit_trigger = d.get("exit_trigger", "").strip()
|
||
if not open_type:
|
||
flash("请选择开仓类型")
|
||
return redirect(url_for("records"))
|
||
if not exit_trigger:
|
||
flash("请选择离场触发")
|
||
return redirect(url_for("records"))
|
||
|
||
symbol = d.get("symbol", "").strip()
|
||
symbol_name = d.get("symbol_name", "").strip()
|
||
market_code = d.get("market_code", "").strip()
|
||
sina_code = d.get("sina_code", "").strip()
|
||
if not symbol or not market_code:
|
||
flash("请从下拉列表选择品种(同花顺合约代码)")
|
||
return redirect(url_for("records"))
|
||
|
||
screenshot = ""
|
||
f = request.files.get("screenshot")
|
||
if f and f.filename:
|
||
fname = secure_filename(f.filename)
|
||
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
|
||
screenshot = f"{ts}_{fname}"
|
||
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()
|
||
if not v:
|
||
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_rr_ratio(direction, entry_price, stop_loss, take_profit)
|
||
actual_pnl = calc_rr_ratio(direction, entry_price, stop_loss, close_price)
|
||
|
||
gross_pnl = num("pnl")
|
||
if gross_pnl is None and entry_price and close_price:
|
||
spec_mult = calc_position_metrics(
|
||
direction, entry_price, stop_loss, take_profit,
|
||
lots, close_price, 0, symbol,
|
||
)
|
||
gross_pnl = spec_mult.get("float_pnl")
|
||
fee = calc_round_trip_fee(
|
||
symbol, entry_price or 0, close_price or 0, lots, open_time, close_time,
|
||
trading_mode=_trading_mode(),
|
||
)
|
||
pnl_net = round((gross_pnl or 0) - fee, 2) if gross_pnl is not None else None
|
||
|
||
auto_kline = bool(d.get("auto_kline"))
|
||
if auto_kline and not screenshot:
|
||
try:
|
||
generated = generate_review_kline_chart(
|
||
symbol=symbol,
|
||
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(
|
||
"""INSERT INTO review_records
|
||
(open_time, close_time, symbol, symbol_name, market_code, sina_code,
|
||
timeframe, direction,
|
||
entry_price, stop_loss, take_profit, close_price, lots,
|
||
holding_duration, initial_pnl, actual_pnl, pnl, fee, pnl_net,
|
||
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, is_emotion, notes)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
open_time, close_time,
|
||
symbol, symbol_name, market_code, sina_code,
|
||
d.get("timeframe", "").strip(),
|
||
direction,
|
||
entry_price, stop_loss, take_profit, close_price, lots,
|
||
holding, initial_pnl, actual_pnl, gross_pnl, fee, pnl_net,
|
||
open_type,
|
||
None,
|
||
None,
|
||
exit_trigger,
|
||
d.get("exit_supplement", "").strip(),
|
||
d.get("watch_after_breakeven", "否"),
|
||
d.get("new_position_while_occupied", "否"),
|
||
screenshot,
|
||
1 if auto_kline else 0,
|
||
d.get("kline_period1", "15m"),
|
||
d.get("kline_period2", "1h"),
|
||
int(d.get("kline_count") or 300),
|
||
d.get("kline_cutoff", "平仓时间"),
|
||
",".join(tags),
|
||
is_emotion,
|
||
d.get("notes", "").strip(),
|
||
),
|
||
)
|
||
hook = getattr(app, "_risk_review_hook", None)
|
||
if hook:
|
||
hook(
|
||
conn,
|
||
",".join(tags),
|
||
exit_trigger,
|
||
d.get("exit_supplement", "").strip(),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
touch_stats_cache()
|
||
flash("复盘记录已保存")
|
||
return redirect(url_for("records"))
|
||
|
||
|
||
@app.route("/del_review/<int:rid>")
|
||
@login_required
|
||
def del_review(rid):
|
||
conn = get_db()
|
||
row = conn.execute("SELECT screenshot FROM review_records WHERE id=?", (rid,)).fetchone()
|
||
if row and row["screenshot"]:
|
||
path = os.path.join(UPLOAD_DIR, row["screenshot"])
|
||
if os.path.isfile(path):
|
||
os.remove(path)
|
||
conn.execute("DELETE FROM review_records WHERE id=?", (rid,))
|
||
conn.commit()
|
||
conn.close()
|
||
touch_stats_cache()
|
||
flash("已删除")
|
||
return redirect(url_for("records"))
|
||
|
||
|
||
@app.route("/uploads/<path:filename>")
|
||
@login_required
|
||
def uploaded_file(filename):
|
||
from flask import send_from_directory
|
||
return send_from_directory(UPLOAD_DIR, filename)
|
||
|
||
|
||
@app.route("/del_record/<int:rid>")
|
||
@login_required
|
||
def del_record(rid):
|
||
conn = get_db()
|
||
conn.execute("DELETE FROM trade_records WHERE id=?", (rid,))
|
||
conn.commit()
|
||
conn.close()
|
||
flash("已删除")
|
||
return redirect(url_for("records"))
|
||
|
||
|
||
@app.route("/stats")
|
||
@login_required
|
||
def stats():
|
||
return render_template("stats.html")
|
||
|
||
|
||
@app.route("/api/stats")
|
||
@login_required
|
||
def api_stats():
|
||
return jsonify(get_stats_data())
|
||
|
||
|
||
@app.route("/api/stats/views")
|
||
@login_required
|
||
def api_stats_views():
|
||
return jsonify({"views": STATS_VIEWS})
|
||
|
||
|
||
@app.route("/api/stats/refresh", methods=["POST"])
|
||
@login_required
|
||
def api_stats_refresh():
|
||
conn = get_db()
|
||
capital = float(get_setting("live_capital", "0") or 0)
|
||
data = refresh_stats_cache(conn, capital)
|
||
conn.close()
|
||
return jsonify(data)
|
||
|
||
|
||
@app.route("/market")
|
||
@login_required
|
||
@require_nav("market")
|
||
def market_page():
|
||
symbol = request.args.get("symbol", "").strip()
|
||
period = request.args.get("period", "15m").strip()
|
||
valid = {p["key"] for p in MARKET_PERIODS}
|
||
if period not in valid:
|
||
period = "15m"
|
||
ctp_st = {}
|
||
try:
|
||
from vnpy_bridge import ctp_status
|
||
from trading_context import get_trading_mode
|
||
|
||
ctp_st = ctp_status(get_trading_mode(get_setting))
|
||
except Exception:
|
||
pass
|
||
return render_template(
|
||
"market.html",
|
||
symbol=symbol,
|
||
period=period,
|
||
market_periods=MARKET_PERIODS,
|
||
quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))),
|
||
ctp_connected=bool(ctp_st.get("connected")),
|
||
)
|
||
|
||
|
||
@app.route("/api/kline")
|
||
@login_required
|
||
def api_kline():
|
||
symbol = request.args.get("symbol", "").strip()
|
||
period = request.args.get("period", "15m").strip()
|
||
if not symbol:
|
||
return jsonify({"error": "请提供合约代码"}), 400
|
||
try:
|
||
from trading_context import get_trading_mode
|
||
|
||
data = fetch_market_klines(
|
||
symbol, period, DB_PATH, trading_mode=get_trading_mode(get_setting),
|
||
)
|
||
except Exception as exc:
|
||
app.logger.warning("kline api failed: %s", exc)
|
||
return jsonify({"error": str(exc)}), 500
|
||
if not data.get("chart_symbol"):
|
||
return jsonify({"error": "无法识别合约代码"}), 400
|
||
if not data.get("bars"):
|
||
return jsonify({"error": "未获取到K线数据,请稍后重试或更换合约"}), 404
|
||
return jsonify(data)
|
||
|
||
|
||
@app.route("/api/kline/stream")
|
||
@login_required
|
||
def api_kline_stream():
|
||
from queue import Empty
|
||
|
||
symbol = request.args.get("symbol", "").strip()
|
||
period = request.args.get("period", "15m").strip()
|
||
market_code = request.args.get("market_code", "").strip()
|
||
sina_code = request.args.get("sina_code", "").strip()
|
||
if not symbol:
|
||
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,
|
||
)
|
||
if kline_data.get("bars"):
|
||
yield sse_format("kline", kline_data)
|
||
yield sse_format(
|
||
"quote",
|
||
build_market_quote_payload(symbol, market_code, sina_code),
|
||
)
|
||
while True:
|
||
try:
|
||
msg = sub.queue.get(timeout=20)
|
||
yield sse_format(msg["event"], msg["data"])
|
||
except Empty:
|
||
yield ": heartbeat\n\n"
|
||
finally:
|
||
kline_hub.unsubscribe(sub)
|
||
|
||
return Response(
|
||
stream_with_context(generate()),
|
||
mimetype="text/event-stream",
|
||
headers={
|
||
"Cache-Control": "no-cache",
|
||
"Connection": "keep-alive",
|
||
"X-Accel-Buffering": "no",
|
||
},
|
||
)
|
||
|
||
|
||
@app.route("/api/market_quote")
|
||
@login_required
|
||
def api_market_quote():
|
||
symbol = request.args.get("symbol", "").strip()
|
||
market_code = request.args.get("market_code", "").strip()
|
||
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))
|
||
|
||
|
||
@app.route("/contract")
|
||
@login_required
|
||
@require_nav("contract")
|
||
def contract_profile_page():
|
||
symbol = request.args.get("symbol", "").strip()
|
||
profile = None
|
||
error = None
|
||
if symbol:
|
||
try:
|
||
profile = get_contract_profile(symbol)
|
||
if not profile:
|
||
error = "未查询到该合约简介,请检查合约代码"
|
||
except Exception as exc:
|
||
app.logger.warning("contract profile failed: %s", exc)
|
||
error = f"查询失败:{exc}"
|
||
return render_template(
|
||
"contract.html",
|
||
symbol=symbol,
|
||
profile=profile,
|
||
error=error,
|
||
)
|
||
|
||
|
||
@app.route("/api/contract_profile")
|
||
@login_required
|
||
def api_contract_profile():
|
||
symbol = request.args.get("symbol", "").strip()
|
||
if not symbol:
|
||
return jsonify({"error": "请提供合约代码"}), 400
|
||
try:
|
||
profile = get_contract_profile(symbol)
|
||
except Exception as exc:
|
||
return jsonify({"error": str(exc)}), 500
|
||
if not profile:
|
||
return jsonify({"error": "未查询到合约简介"}), 404
|
||
return jsonify(profile)
|
||
|
||
|
||
@app.route("/fees", methods=["GET", "POST"])
|
||
@login_required
|
||
@require_nav("fees")
|
||
def fees():
|
||
from trading_context import get_trading_mode
|
||
from ctp_fee_sync import sync_fees_from_ctp
|
||
from vnpy_bridge import ctp_status
|
||
|
||
mode = get_trading_mode(get_setting)
|
||
if request.method == "POST":
|
||
action = request.form.get("action")
|
||
if action == "fee_source":
|
||
fs = request.form.get("fee_source_mode", "ctp").strip()
|
||
set_setting("fee_source_mode", fs if fs in ("ctp", "local") else "ctp")
|
||
flash("手续费数据源已保存")
|
||
elif action == "sync_ctp":
|
||
count, msg = sync_fees_from_ctp(mode)
|
||
flash(msg)
|
||
elif action == "multiplier":
|
||
try:
|
||
mult = float(request.form.get("fee_multiplier", "2"))
|
||
if mult < 0:
|
||
flash("倍率不能为负数")
|
||
else:
|
||
set_setting("fee_multiplier", str(mult))
|
||
flash(f"手续费倍率已保存:标准 × {mult}")
|
||
except ValueError:
|
||
flash("请输入有效倍率")
|
||
elif action == "sync":
|
||
mult = float(get_setting("fee_multiplier", "2") or 2)
|
||
count, msg = sync_fees_from_akshare(mult)
|
||
flash(msg if count else msg)
|
||
elif action == "reload_json":
|
||
n = load_fee_rates_from_json()
|
||
flash(f"已从本地 JSON 加载 {n} 个品种费率")
|
||
elif action == "save_row":
|
||
product = request.form.get("product", "").strip().lower()
|
||
if not product:
|
||
flash("品种代码不能为空")
|
||
else:
|
||
upsert_fee_rate(product, {
|
||
"exchange": request.form.get("exchange", "").strip(),
|
||
"mult": int(request.form.get("mult") or 10),
|
||
"open_fixed": float(request.form.get("open_fixed") or 0),
|
||
"open_ratio": float(request.form.get("open_ratio") or 0),
|
||
"close_yesterday_fixed": float(request.form.get("close_yesterday_fixed") or 0),
|
||
"close_yesterday_ratio": float(request.form.get("close_yesterday_ratio") or 0),
|
||
"close_today_fixed": float(request.form.get("close_today_fixed") or 0),
|
||
"close_today_ratio": float(request.form.get("close_today_ratio") or 0),
|
||
"source": "manual",
|
||
})
|
||
flash(f"已保存 {product} 费率")
|
||
return redirect(url_for("fees"))
|
||
|
||
rates = list_fee_rates_for_ui()
|
||
fee_counts = count_fee_rates_by_source()
|
||
multiplier = get_setting("fee_multiplier", "2")
|
||
fee_source_mode = get_fee_source_mode()
|
||
ctp_st = ctp_status(mode)
|
||
return render_template(
|
||
"fees.html",
|
||
rates=rates,
|
||
fee_counts=fee_counts,
|
||
multiplier=multiplier,
|
||
fee_source_mode=fee_source_mode,
|
||
ctp_connected=bool(ctp_st.get("connected")),
|
||
)
|
||
|
||
|
||
@app.route("/settings", methods=["GET", "POST"])
|
||
@login_required
|
||
def settings():
|
||
if request.method == "POST":
|
||
action = request.form.get("action")
|
||
if action == "wechat":
|
||
webhook = request.form.get("wechat_webhook", "").strip()
|
||
set_setting("wechat_webhook", webhook)
|
||
flash("企业微信配置已保存")
|
||
elif action == "trading":
|
||
mode = request.form.get("trading_mode", "simulation").strip()
|
||
if mode not in ("simulation", "live"):
|
||
mode = "simulation"
|
||
sizing = request.form.get("position_sizing_mode", "risk").strip()
|
||
if sizing not in ("fixed", "risk"):
|
||
sizing = "risk"
|
||
set_setting("trading_mode", mode)
|
||
set_setting("position_sizing_mode", sizing)
|
||
try:
|
||
rp = float(request.form.get("risk_percent", "1") or 1)
|
||
set_setting("risk_percent", str(max(0.1, min(100.0, rp))))
|
||
except ValueError:
|
||
flash("风险比例无效")
|
||
return redirect(url_for("settings"))
|
||
flash("交易模式已保存")
|
||
elif action == "nav":
|
||
items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES}
|
||
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("密码修改成功")
|
||
return redirect(url_for("settings"))
|
||
|
||
webhook = get_setting("wechat_webhook")
|
||
username = get_setting("admin_username")
|
||
ctp_st = {}
|
||
try:
|
||
from vnpy_bridge import ctp_status
|
||
from trading_context import get_trading_mode
|
||
|
||
ctp_st = ctp_status(get_trading_mode(get_setting))
|
||
except Exception:
|
||
pass
|
||
return render_template(
|
||
"settings.html",
|
||
webhook=webhook,
|
||
username=username,
|
||
quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))),
|
||
trading_mode=get_setting("trading_mode", "simulation"),
|
||
position_sizing_mode=get_setting("position_sizing_mode", "risk"),
|
||
risk_percent=get_setting("risk_percent", "1"),
|
||
nav_items=get_nav_items(get_setting),
|
||
nav_toggles=NAV_TOGGLES,
|
||
)
|
||
|
||
|
||
install_trading(
|
||
app,
|
||
login_required=login_required,
|
||
require_nav=require_nav,
|
||
get_db=get_db,
|
||
get_setting=get_setting,
|
||
set_setting=set_setting,
|
||
fetch_price=fetch_price,
|
||
send_wechat_msg=send_wechat_msg,
|
||
)
|
||
|
||
try_init_vnpy({})
|
||
|
||
start_background_threads()
|
||
|
||
# —————————————— 启动 ——————————————
|
||
|
||
if __name__ == "__main__":
|
||
app.run(host=HOST, port=PORT, debug=DEBUG)
|