持仓监控平仓自动记入交易记录,新增交易记录页与实盘资金设置
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,7 @@ from flask import (
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from symbols import search_symbols, ths_to_codes
|
||||
from contract_specs import calc_position_metrics
|
||||
from kline_chart import generate_review_kline_chart
|
||||
from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label
|
||||
|
||||
@@ -62,6 +63,28 @@ def calc_holding_duration(open_time: str, close_time: str) -> str:
|
||||
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:
|
||||
@@ -213,6 +236,23 @@ def init_db():
|
||||
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)''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -551,7 +591,48 @@ def api_plan_prices():
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@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)
|
||||
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,
|
||||
**metrics,
|
||||
})
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@login_required
|
||||
def index():
|
||||
return redirect(url_for("plans"))
|
||||
@@ -647,8 +728,11 @@ def keys():
|
||||
history = conn.execute(
|
||||
"SELECT * FROM key_monitors WHERE status='archived' ORDER BY archived_at DESC LIMIT 100"
|
||||
).fetchall()
|
||||
positions = conn.execute(
|
||||
"SELECT * FROM position_monitors WHERE status='active' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return render_template("keys.html", keys=key_list, history=history)
|
||||
return render_template("keys.html", keys=key_list, history=history, positions=positions)
|
||||
|
||||
|
||||
@app.route("/add_key", methods=["POST"])
|
||||
@@ -679,6 +763,179 @@ def add_key():
|
||||
return redirect(url_for("keys"))
|
||||
|
||||
|
||||
@app.route("/add_position", methods=["POST"])
|
||||
@login_required
|
||||
def add_position():
|
||||
d = request.form
|
||||
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("keys"))
|
||||
entry = float(d["entry_price"])
|
||||
sl = float(d["stop_loss"])
|
||||
tp = float(d["take_profit"])
|
||||
direction = d.get("direction", "").strip()
|
||||
if not direction:
|
||||
direction = "long" if sl < entry else "short"
|
||||
open_time = d.get("open_time", "").strip()
|
||||
lots = float(d.get("lots") or 1)
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"""INSERT INTO position_monitors
|
||||
(symbol, symbol_name, market_code, sina_code, direction,
|
||||
lots, entry_price, stop_loss, take_profit, open_time)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
symbol, symbol_name, market_code, sina_code, direction,
|
||||
lots, entry, sl, tp, open_time,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("持仓已添加")
|
||||
return redirect(url_for("keys"))
|
||||
|
||||
|
||||
@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("keys"))
|
||||
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("keys"))
|
||||
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
|
||||
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, result)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
sym, row["symbol_name"], market, sina, "持仓监控", direction,
|
||||
entry, sl, tp, close_price, lots, metrics["margin"],
|
||||
minutes, open_time, close_time, pnl, result,
|
||||
),
|
||||
)
|
||||
conn.execute("DELETE FROM position_monitors WHERE id=?", (pid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash(f"已平仓,盈亏 {pnl:.2f} 元,已记入交易记录")
|
||||
return redirect(url_for("keys"))
|
||||
|
||||
|
||||
@app.route("/trades")
|
||||
@login_required
|
||||
def trades():
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM trade_logs ORDER BY id DESC LIMIT 500").fetchall()
|
||||
conn.close()
|
||||
return render_template("trades.html", trades=rows)
|
||||
|
||||
|
||||
@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()
|
||||
flash("交易记录已核对保存")
|
||||
return redirect(url_for("trades"))
|
||||
|
||||
|
||||
@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()
|
||||
flash("已删除")
|
||||
return redirect(url_for("trades"))
|
||||
|
||||
|
||||
@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("trades"))
|
||||
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"],
|
||||
}
|
||||
return redirect(url_for("records", **{k: v for k, v in q.items() if v is not None}))
|
||||
|
||||
|
||||
@app.route("/del_key/<int:pid>")
|
||||
@login_required
|
||||
def del_key(pid):
|
||||
@@ -719,6 +976,13 @@ def records():
|
||||
).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,
|
||||
@@ -726,6 +990,7 @@ def records():
|
||||
preset=preset,
|
||||
start=start,
|
||||
end=end,
|
||||
prefill=prefill,
|
||||
open_types=OPEN_TYPES,
|
||||
exit_triggers=EXIT_TRIGGERS,
|
||||
behavior_tags=BEHAVIOR_TAGS,
|
||||
@@ -941,6 +1206,17 @@ def settings():
|
||||
webhook = request.form.get("wechat_webhook", "").strip()
|
||||
set_setting("wechat_webhook", webhook)
|
||||
flash("企业微信配置已保存")
|
||||
elif action == "capital":
|
||||
raw = request.form.get("live_capital", "").strip()
|
||||
try:
|
||||
val = float(raw)
|
||||
if val < 0:
|
||||
flash("实盘资金不能为负数")
|
||||
else:
|
||||
set_setting("live_capital", str(val))
|
||||
flash("实盘资金已保存")
|
||||
except ValueError:
|
||||
flash("请输入有效的实盘资金金额")
|
||||
elif action == "password":
|
||||
old_p = request.form.get("old_password", "")
|
||||
new_p = request.form.get("new_password", "")
|
||||
@@ -959,10 +1235,12 @@ def settings():
|
||||
|
||||
webhook = get_setting("wechat_webhook")
|
||||
username = get_setting("admin_username")
|
||||
live_capital = get_setting("live_capital", "0")
|
||||
return render_template(
|
||||
"settings.html",
|
||||
webhook=webhook,
|
||||
username=username,
|
||||
live_capital=live_capital,
|
||||
quote_label=get_quote_source_label(),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user