增加模拟交易

This commit is contained in:
aaron 2026-05-16 21:47:36 +08:00
parent abb70bbe3e
commit a292982845
27 changed files with 1354 additions and 200 deletions

View File

@ -3,7 +3,7 @@
import argparse
import sys
from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, onchain_monitor, price_tracker, review_engine, sentiment_monitor
from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, onchain_monitor, paper_trader, price_tracker, review_engine, sentiment_monitor
def build_parser():
@ -18,6 +18,9 @@ def build_parser():
tracker = subparsers.add_parser("tracker", help="运行价格跟踪")
paper = subparsers.add_parser("paper-trader", help="运行模拟交易账本同步")
paper.add_argument("--limit", type=int, default=100, help="本轮最多处理的可执行推荐数量")
review = subparsers.add_parser("review", help="运行复盘")
review.add_argument("--compact", action="store_true", help="输出紧凑 JSON")
review.add_argument("--no-push", action="store_true", help="只运行复盘,不发飞书")
@ -50,6 +53,8 @@ def main():
return altcoin_confirm.main(compact=args.compact)
if args.command == "tracker":
return price_tracker.main()
if args.command == "paper-trader":
return paper_trader.main(limit=args.limit)
if args.command == "review":
return review_engine.run_review(push_enabled=not args.no_push, compact=args.compact)
if args.command == "event":

View File

@ -277,7 +277,11 @@ def update_recommendation_tracking(rec_id, current_price):
这样 TP1 后继续推高的收益会继续计入 current/max_pnl
"""
conn = get_conn()
row = conn.execute("SELECT entry_price, max_price, min_price, symbol FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
row = conn.execute("""
SELECT entry_price, max_price, min_price, symbol, status, action_status,
execution_status, display_bucket, entry_triggered
FROM recommendation WHERE id=%s
""", (rec_id,)).fetchone()
if not row:
conn.close()
return
@ -292,20 +296,26 @@ def update_recommendation_tracking(rec_id, current_price):
max_pnl_pct = round((new_max / entry_price - 1) * 100, 2)
max_drawdown_pct = round((new_min / entry_price - 1) * 100, 2)
is_executed = (
int(row["entry_triggered"] or 0) == 1
or row["display_bucket"] == "position"
or row["execution_status"] in ("holding", "completed")
or is_executed_lifecycle(row["status"], row["action_status"], row["execution_status"])
)
status = "active"
tp1_reached = False
rec = conn.execute("SELECT stop_loss, tp1, tp2, status, hit_tp1_time FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
if rec and rec["status"] == "active":
if rec["tp2"] and current_price >= rec["tp2"]:
status = "hit_tp2"
elif rec["stop_loss"] and current_price <= rec["stop_loss"]:
status = "stopped_out"
elif rec["tp1"] and current_price >= rec["tp1"]:
status = "hit_tp1"
tp1_reached = True
elif rec["tp1"] == 0 and pnl_pct >= 15:
status = "hit_tp1"
tp1_reached = True
elif is_executed and rec["stop_loss"] and current_price <= rec["stop_loss"]:
status = "stopped_out"
now = datetime.now().isoformat()
if status != "active":

View File

@ -186,7 +186,59 @@ def get_observation_candidates(limit=50):
}
def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False):
def _archive_filter_where(archive_filter):
archive_filter = str(archive_filter or "").strip()
if archive_filter == "executed":
return " AND EXISTS (SELECT 1 FROM paper_trades ptf WHERE ptf.recommendation_id = recommendation.id)"
if archive_filter == "invalid":
return """
AND NOT EXISTS (SELECT 1 FROM paper_trades ptf WHERE ptf.recommendation_id = recommendation.id)
AND (
status IN ('expired','invalid','archived','stopped_out')
OR COALESCE(execution_status, '') = 'invalid'
)
"""
return ""
def _attach_paper_trade(item):
paper_id = item.get("paper_trade_id")
if not paper_id:
item["paper_trade"] = None
item["paper_trade_executed"] = False
item["paper_trade_status"] = ""
return item
paper = {
"id": paper_id,
"recommendation_id": item.get("paper_recommendation_id") or item.get("id"),
"symbol": item.get("paper_symbol") or item.get("symbol"),
"status": item.get("paper_status") or "",
"opened_at": item.get("paper_opened_at") or "",
"closed_at": item.get("paper_closed_at") or "",
"entry_price": item.get("paper_entry_price") or 0,
"exit_price": item.get("paper_exit_price") or 0,
"current_price": item.get("paper_current_price") or 0,
"stop_loss": item.get("paper_stop_loss") or 0,
"tp1": item.get("paper_tp1") or 0,
"tp2": item.get("paper_tp2") or 0,
"trailing_stop": item.get("paper_trailing_stop") or 0,
"max_price": item.get("paper_max_price") or 0,
"min_price": item.get("paper_min_price") or 0,
"pnl_pct": item.get("paper_pnl_pct") or 0,
"realized_pnl_pct": item.get("paper_realized_pnl_pct") or 0,
"realized_pnl_usdt": item.get("paper_realized_pnl_usdt") or 0,
"exit_reason": item.get("paper_exit_reason") or "",
"updated_at": item.get("paper_updated_at") or "",
}
item["paper_trade"] = paper
item["paper_trade_executed"] = True
item["paper_trade_status"] = paper["status"]
item["paper_trade_closed"] = paper["status"] == "closed"
return item
def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False, archive_filter=""):
"""获取推荐列表。"""
conn = get_conn()
version = str(version or "").strip()
@ -199,18 +251,15 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
except Exception:
offset = 0
result_where = EXECUTED_TRADE_WHERE
archive_where = "(status != 'active' OR COALESCE(display_bucket, '') = 'history' OR COALESCE(execution_status, '') IN ('invalid','completed'))"
archive_filter_where = _archive_filter_where(archive_filter)
filtered_archive_where = archive_where + archive_filter_where
version_where = " AND strategy_version=%s" if version else ""
params = [version] if version else []
total = None
summary = None
version_counts = []
realized_pnl_case = (
f"CASE WHEN {FAILURE_CASE} THEN COALESCE(pnl_pct,0) "
f"WHEN {SUCCESS_CASE} THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) "
"ELSE 0 END"
)
if decision_only:
if with_meta:
@ -220,7 +269,7 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
SELECT symbol
FROM recommendation
WHERE """
+ result_where
+ filtered_archive_where
+ version_where
+ """
GROUP BY symbol
@ -233,41 +282,57 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
"""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN """
+ SUCCESS_CASE
+ """ THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN """
+ FAILURE_CASE
+ """ THEN 1 ELSE 0 END) AS failure_count,
SUM("""
+ realized_pnl_case
+ """) AS total_pnl,
MAX("""
+ realized_pnl_case
+ """) AS best_pnl,
AVG(CASE WHEN """
+ FAILURE_CASE
+ """ THEN COALESCE(pnl_pct,0) END) AS avg_failure_pnl
SUM(CASE WHEN paper_trade_id IS NOT NULL THEN 1 ELSE 0 END) AS executed_count,
SUM(CASE WHEN paper_trade_closed = TRUE THEN 1 ELSE 0 END) AS completed_count,
SUM(CASE WHEN paper_trade_id IS NULL AND (status IN ('expired','invalid','archived','stopped_out') OR COALESCE(execution_status,'')='invalid') THEN 1 ELSE 0 END) AS invalid_count,
SUM(CASE WHEN paper_trade_id IS NULL THEN 1 ELSE 0 END) AS not_executed_count,
SUM(CASE WHEN status IN ('hit_tp1','hit_tp2') THEN 1 ELSE 0 END) AS legacy_success_count,
SUM(CASE WHEN status='stopped_out' THEN 1 ELSE 0 END) AS legacy_failure_count,
SUM(CASE
WHEN status='stopped_out' THEN COALESCE(pnl_pct,0)
WHEN status IN ('hit_tp1','hit_tp2') THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0)
ELSE 0
END) AS legacy_total_pnl,
MAX(CASE
WHEN status='stopped_out' THEN COALESCE(pnl_pct,0)
WHEN status IN ('hit_tp1','hit_tp2') THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0)
ELSE 0
END) AS legacy_best_pnl,
AVG(CASE WHEN status='stopped_out' THEN COALESCE(pnl_pct,0) END) AS legacy_avg_failure_pnl
FROM (
SELECT r.*
SELECT r.*,
pt.id AS paper_trade_id,
pt.status AS paper_trade_status,
CASE WHEN pt.status='closed' THEN TRUE ELSE FALSE END AS paper_trade_closed
FROM recommendation r
JOIN (
SELECT symbol, MAX(id) AS max_id
FROM recommendation
WHERE """
+ result_where
+ archive_where
+ version_where
+ """
GROUP BY symbol
) latest ON latest.max_id = r.id
)
LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id
) x
""",
tuple(params),
).fetchone()
summary = dict(summary_row) if summary_row else {}
for key in ("total", "success_count", "failure_count", "total_pnl", "best_pnl", "avg_failure_pnl"):
for key in (
"total", "executed_count", "completed_count", "invalid_count", "not_executed_count",
"legacy_success_count", "legacy_failure_count", "legacy_total_pnl", "legacy_best_pnl", "legacy_avg_failure_pnl",
):
if summary.get(key) is None:
summary[key] = 0
# Backward-compatible placeholders. The recommendation archive no
# longer treats signal history as trade PnL; paper_trades owns PnL.
summary["success_count"] = summary.get("legacy_success_count", 0)
summary["failure_count"] = summary.get("legacy_failure_count", 0)
summary["total_pnl"] = summary.get("legacy_total_pnl", 0)
summary["best_pnl"] = summary.get("legacy_best_pnl", 0)
summary["avg_failure_pnl"] = summary.get("legacy_avg_failure_pnl", 0)
vc_rows = conn.execute(
"""
@ -277,7 +342,7 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
SELECT symbol, MAX(id) AS max_id
FROM recommendation
WHERE """
+ result_where
+ archive_where
+ """
GROUP BY symbol
) latest ON latest.max_id = r.id
@ -289,16 +354,37 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
rows = conn.execute(
"""
SELECT r.*,
SELECT r.*,
lpc.price AS latest_cache_price,
lpc.updated_at AS latest_cache_updated_at
lpc.updated_at AS latest_cache_updated_at,
pt.id AS paper_trade_id,
pt.recommendation_id AS paper_recommendation_id,
pt.symbol AS paper_symbol,
pt.status AS paper_status,
pt.opened_at AS paper_opened_at,
pt.closed_at AS paper_closed_at,
pt.entry_price AS paper_entry_price,
pt.exit_price AS paper_exit_price,
pt.current_price AS paper_current_price,
pt.stop_loss AS paper_stop_loss,
pt.tp1 AS paper_tp1,
pt.tp2 AS paper_tp2,
pt.trailing_stop AS paper_trailing_stop,
pt.max_price AS paper_max_price,
pt.min_price AS paper_min_price,
pt.pnl_pct AS paper_pnl_pct,
pt.realized_pnl_pct AS paper_realized_pnl_pct,
pt.realized_pnl_usdt AS paper_realized_pnl_usdt,
pt.exit_reason AS paper_exit_reason,
pt.updated_at AS paper_updated_at
FROM recommendation r
LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol
LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id
JOIN (
SELECT symbol, MAX(id) AS max_id
FROM recommendation
WHERE """
+ result_where
+ filtered_archive_where
+ version_where
+ """
GROUP BY symbol
@ -330,6 +416,7 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
item["recommendation_result"] = rec_result
item["recommendation_result_label"] = rec_result_label
_derive_execution_fields(item)
_attach_paper_trade(item)
result.append(item)
if not with_meta:

View File

@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS paper_trades (
id BIGSERIAL PRIMARY KEY,
recommendation_id BIGINT NOT NULL UNIQUE,
symbol TEXT NOT NULL,
side TEXT NOT NULL DEFAULT 'long',
status TEXT NOT NULL DEFAULT 'open',
opened_at TEXT NOT NULL,
closed_at TEXT DEFAULT '',
entry_price DOUBLE PRECISION NOT NULL,
exit_price DOUBLE PRECISION DEFAULT 0,
qty DOUBLE PRECISION NOT NULL,
notional_usdt DOUBLE PRECISION NOT NULL,
stop_loss DOUBLE PRECISION DEFAULT 0,
tp1 DOUBLE PRECISION DEFAULT 0,
tp2 DOUBLE PRECISION DEFAULT 0,
trailing_stop DOUBLE PRECISION DEFAULT 0,
max_price DOUBLE PRECISION DEFAULT 0,
min_price DOUBLE PRECISION DEFAULT 0,
current_price DOUBLE PRECISION DEFAULT 0,
pnl_pct DOUBLE PRECISION DEFAULT 0,
realized_pnl_pct DOUBLE PRECISION DEFAULT 0,
realized_pnl_usdt DOUBLE PRECISION DEFAULT 0,
fee_usdt DOUBLE PRECISION DEFAULT 0,
exit_reason TEXT DEFAULT '',
source_status TEXT DEFAULT '',
source_action TEXT DEFAULT '',
strategy_version TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_paper_trades_status_updated ON paper_trades(status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_paper_trades_symbol_opened ON paper_trades(symbol, opened_at DESC);
CREATE INDEX IF NOT EXISTS idx_paper_trades_recommendation ON paper_trades(recommendation_id);
CREATE TABLE IF NOT EXISTS paper_trade_events (
id BIGSERIAL PRIMARY KEY,
trade_id BIGINT NOT NULL,
recommendation_id BIGINT NOT NULL,
symbol TEXT NOT NULL,
event_type TEXT NOT NULL,
event_time TEXT NOT NULL,
price DOUBLE PRECISION DEFAULT 0,
pnl_pct DOUBLE PRECISION DEFAULT 0,
message TEXT DEFAULT '',
detail_json TEXT DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_paper_trade_events_trade_time ON paper_trade_events(trade_id, event_time DESC);
CREATE INDEX IF NOT EXISTS idx_paper_trade_events_symbol_time ON paper_trade_events(symbol, event_time DESC);

View File

@ -0,0 +1,15 @@
ALTER TABLE paper_trades
ADD COLUMN IF NOT EXISTS margin_usdt DOUBLE PRECISION DEFAULT 0;
ALTER TABLE paper_trades
ADD COLUMN IF NOT EXISTS leverage DOUBLE PRECISION DEFAULT 5;
UPDATE paper_trades
SET leverage = 5
WHERE leverage IS NULL OR leverage <= 0;
UPDATE paper_trades
SET margin_usdt = CASE
WHEN margin_usdt IS NULL OR margin_usdt <= 0 THEN COALESCE(notional_usdt, 0) / NULLIF(leverage, 0)
ELSE margin_usdt
END;

View File

@ -0,0 +1,14 @@
UPDATE paper_trades
SET leverage = 5
WHERE leverage IS NULL OR leverage <= 1;
UPDATE paper_trades
SET margin_usdt = COALESCE(notional_usdt, 0) / NULLIF(leverage, 0)
WHERE notional_usdt IS NOT NULL
AND leverage IS NOT NULL
AND leverage > 0
AND (
margin_usdt IS NULL
OR margin_usdt <= 0
OR margin_usdt >= notional_usdt
);

448
app/db/paper_trading.py Normal file
View File

@ -0,0 +1,448 @@
"""Paper trading ledger for separating signal quality from trade PnL."""
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta
from app.db.schema import get_conn
def _now() -> str:
return datetime.now().isoformat()
def _safe_float(value, default: float = 0.0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except Exception:
return default
def _safe_int(value, default: int = 0) -> int:
try:
return int(value or 0)
except Exception:
return default
def paper_trading_enabled() -> bool:
return os.getenv("ALPHAX_PAPER_TRADING_ENABLED", "1").strip().lower() not in {"0", "false", "no", "off"}
def default_account_equity_usdt() -> float:
return max(1.0, _safe_float(os.getenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", "20000"), 20000.0))
def default_leverage() -> float:
return max(1.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_LEVERAGE", "5"), 5.0))
def default_notional_usdt() -> float:
return max(1.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "5000"), 5000.0))
def default_margin_usdt() -> float:
return round(default_notional_usdt() / default_leverage(), 8)
def default_fee_rate() -> float:
return max(0.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0.001"), 0.001))
def default_slippage_pct() -> float:
return max(0.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0.05"), 0.05))
def _loads_json(value, fallback=None):
try:
if isinstance(value, str) and value.strip():
return json.loads(value)
if value:
return value
except Exception:
pass
return fallback if fallback is not None else {}
def _entry_plan(rec: dict) -> dict:
plan = rec.get("entry_plan")
if isinstance(plan, dict):
return plan
return _loads_json(rec.get("entry_plan_json"), {})
def _open_price(current_price: float) -> float:
return round(current_price * (1 + default_slippage_pct() / 100), 12)
def _close_price(current_price: float) -> float:
return round(current_price * (1 - default_slippage_pct() / 100), 12)
def _trade_pnl_pct(entry_price: float, current_price: float) -> float:
if entry_price <= 0 or current_price <= 0:
return 0.0
return round((current_price / entry_price - 1) * 100, 4)
def _account_return_pct(pnl_usdt: float, account_equity: float | None = None) -> float:
equity = max(1.0, _safe_float(account_equity, default_account_equity_usdt()))
return round(_safe_float(pnl_usdt) / equity * 100, 4)
def _margin_roi_pct(pnl_usdt: float, margin_usdt: float) -> float:
margin = max(1.0, _safe_float(margin_usdt, default_margin_usdt()))
return round(_safe_float(pnl_usdt) / margin * 100, 4)
def _trade_margin(trade: dict) -> float:
margin = _safe_float(trade.get("margin_usdt"))
if margin > 0:
return margin
leverage = max(1.0, _safe_float(trade.get("leverage"), default_leverage()))
return round(_safe_float(trade.get("notional_usdt")) / leverage, 8)
def _decorate_trade(trade: dict) -> dict:
item = dict(trade)
notional = _safe_float(item.get("notional_usdt"), default_notional_usdt())
leverage = max(1.0, _safe_float(item.get("leverage"), default_leverage()))
margin = _trade_margin({"margin_usdt": item.get("margin_usdt"), "notional_usdt": notional, "leverage": leverage})
unrealized = round(notional * _safe_float(item.get("pnl_pct")) / 100, 8)
realized = _safe_float(item.get("realized_pnl_usdt"))
effective_pnl = realized if item.get("status") == "closed" else unrealized
item["notional_usdt"] = notional
item["leverage"] = leverage
item["margin_usdt"] = margin
item["unrealized_pnl_usdt"] = unrealized
item["margin_roi_pct"] = _margin_roi_pct(effective_pnl, margin)
item["account_return_pct"] = _account_return_pct(effective_pnl)
item["account_equity_usdt"] = default_account_equity_usdt()
return item
def _record_event(conn, trade_id: int, rec_id: int, symbol: str, event_type: str, price: float, pnl_pct: float, message: str, detail=None, event_time: str = ""):
conn.execute(
"""
INSERT INTO paper_trade_events (
trade_id, recommendation_id, symbol, event_type, event_time,
price, pnl_pct, message, detail_json
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
trade_id,
rec_id,
symbol,
event_type,
event_time or _now(),
price,
pnl_pct,
message,
json.dumps(detail or {}, ensure_ascii=False, default=str),
),
)
def _open_trade(conn, rec: dict, current_price: float, event_time: str) -> dict:
rec_id = _safe_int(rec.get("id"))
symbol = str(rec.get("symbol") or "").strip().upper()
plan = _entry_plan(rec)
entry_price = _open_price(current_price)
notional = default_notional_usdt()
leverage = default_leverage()
margin = default_margin_usdt()
qty = round(notional / entry_price, 12) if entry_price > 0 else 0
stop_loss = _safe_float(rec.get("stop_loss") or plan.get("stop_loss"))
tp1 = _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1"))
tp2 = _safe_float(rec.get("tp2") or plan.get("tp2") or plan.get("take_profit_2"))
fee = round(notional * default_fee_rate(), 8)
now = event_time or _now()
row = conn.execute(
"""
INSERT INTO paper_trades (
recommendation_id, symbol, side, status, opened_at,
entry_price, qty, notional_usdt, margin_usdt, leverage, stop_loss, tp1, tp2,
max_price, min_price, current_price, pnl_pct, fee_usdt,
source_status, source_action, strategy_version, created_at, updated_at
) VALUES (%s,%s,'long','open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s)
ON CONFLICT(recommendation_id) DO NOTHING
RETURNING id
""",
(
rec_id,
symbol,
now,
entry_price,
qty,
notional,
margin,
leverage,
stop_loss,
tp1,
tp2,
entry_price,
entry_price,
entry_price,
fee,
rec.get("execution_status") or "",
rec.get("action_status") or "",
rec.get("strategy_version") or "",
now,
now,
),
).fetchone()
if not row:
return {"opened": False, "reason": "already_exists"}
trade_id = row["id"]
_record_event(
conn,
trade_id,
rec_id,
symbol,
"open",
entry_price,
0.0,
"模拟交易开仓:仅用于策略收益验证,不代表真实成交",
{
"notional_usdt": notional,
"margin_usdt": margin,
"leverage": leverage,
"qty": qty,
"fee_usdt": fee,
"slippage_pct": default_slippage_pct(),
"source_status": rec.get("execution_status") or "",
"source_action": rec.get("action_status") or "",
},
now,
)
return {
"opened": True,
"trade_id": trade_id,
"entry_price": entry_price,
"qty": qty,
"notional_usdt": notional,
"margin_usdt": margin,
"leverage": leverage,
}
def _close_trade(conn, trade: dict, current_price: float, reason: str, event_time: str) -> dict:
entry_price = _safe_float(trade.get("entry_price"))
exit_price = _close_price(current_price)
pnl_pct = _trade_pnl_pct(entry_price, exit_price)
notional = _safe_float(trade.get("notional_usdt"))
open_fee = _safe_float(trade.get("fee_usdt"))
close_fee = round(notional * default_fee_rate(), 8)
total_fee = round(open_fee + close_fee, 8)
pnl_usdt = round(notional * pnl_pct / 100 - total_fee, 8)
now = event_time or _now()
conn.execute(
"""
UPDATE paper_trades
SET status='closed',
closed_at=%s,
exit_price=%s,
current_price=%s,
pnl_pct=%s,
realized_pnl_pct=%s,
realized_pnl_usdt=%s,
fee_usdt=%s,
exit_reason=%s,
updated_at=%s
WHERE id=%s AND status='open'
""",
(
now,
exit_price,
exit_price,
pnl_pct,
pnl_pct,
pnl_usdt,
total_fee,
reason,
now,
trade["id"],
),
)
_record_event(
conn,
trade["id"],
trade["recommendation_id"],
trade["symbol"],
"close",
exit_price,
pnl_pct,
f"模拟交易平仓:{reason}",
{"realized_pnl_usdt": pnl_usdt, "fee_usdt": total_fee},
now,
)
return {"closed": True, "trade_id": trade["id"], "exit_reason": reason, "pnl_pct": pnl_pct, "pnl_usdt": pnl_usdt}
def _update_open_trade(conn, trade: dict, current_price: float, event_time: str) -> dict:
entry_price = _safe_float(trade.get("entry_price"))
old_max = _safe_float(trade.get("max_price")) or entry_price
old_min = _safe_float(trade.get("min_price")) or entry_price
new_max = max(old_max, current_price)
new_min = min(old_min, current_price)
pnl_pct = _trade_pnl_pct(entry_price, current_price)
stop_loss = _safe_float(trade.get("stop_loss"))
tp2 = _safe_float(trade.get("tp2"))
tp1 = _safe_float(trade.get("tp1"))
reason = ""
if stop_loss > 0 and current_price <= stop_loss:
reason = "stop_loss"
elif tp2 > 0 and current_price >= tp2:
reason = "tp2"
elif tp1 > 0 and current_price >= tp1:
reason = "tp1"
if reason:
return _close_trade(conn, trade, current_price, reason, event_time)
conn.execute(
"""
UPDATE paper_trades
SET current_price=%s,
max_price=%s,
min_price=%s,
pnl_pct=%s,
updated_at=%s
WHERE id=%s AND status='open'
""",
(current_price, new_max, new_min, pnl_pct, event_time or _now(), trade["id"]),
)
return {"updated": True, "trade_id": trade["id"], "pnl_pct": pnl_pct}
def sync_recommendation(rec: dict, current_price: float, event_time: str = "") -> dict:
"""Open/update paper trade for one recommendation.
This is intentionally independent from recommendation PnL fields. A
recommendation can be a signal; only this ledger represents simulated
execution.
"""
if not paper_trading_enabled():
return {"enabled": False, "skipped": True, "reason": "disabled"}
rec_id = _safe_int(rec.get("id"))
symbol = str(rec.get("symbol") or "").strip().upper()
current_price = _safe_float(current_price)
if rec_id <= 0 or not symbol or current_price <= 0:
return {"enabled": True, "skipped": True, "reason": "invalid_input"}
execution_status = str(rec.get("execution_status") or "").strip()
action_status = str(rec.get("action_status") or "").strip()
event_time = event_time or _now()
conn = get_conn()
try:
trade = conn.execute("SELECT * FROM paper_trades WHERE recommendation_id=%s", (rec_id,)).fetchone()
if trade:
trade = dict(trade)
if trade.get("status") == "open":
result = _update_open_trade(conn, trade, current_price, event_time)
conn.commit()
return result
conn.close()
return {"skipped": True, "reason": "already_closed", "trade_id": trade.get("id")}
if execution_status != "buy_now" and action_status != "可即刻买入":
conn.close()
return {"skipped": True, "reason": "not_buy_now"}
result = _open_trade(conn, rec, current_price, event_time)
conn.commit()
return result
except Exception:
conn.rollback()
raise
finally:
try:
conn.close()
except Exception:
pass
def get_paper_trading_summary(days: int = 30) -> dict:
days = max(1, min(_safe_int(days, 30), 365))
cutoff = (datetime.now() - timedelta(days=days)).isoformat()
conn = get_conn()
try:
rows = conn.execute(
"""
SELECT * FROM paper_trades
WHERE opened_at >= %s
ORDER BY opened_at DESC, id DESC
""",
(cutoff,),
).fetchall()
finally:
conn.close()
items = [_decorate_trade(dict(r)) for r in rows]
open_items = [x for x in items if x.get("status") == "open"]
closed_items = [x for x in items if x.get("status") == "closed"]
wins = [x for x in closed_items if _safe_float(x.get("realized_pnl_pct")) > 0]
losses = [x for x in closed_items if _safe_float(x.get("realized_pnl_pct")) <= 0]
total_realized = round(sum(_safe_float(x.get("realized_pnl_usdt")) for x in closed_items), 4)
avg_realized_pct = round(sum(_safe_float(x.get("realized_pnl_pct")) for x in closed_items) / len(closed_items), 4) if closed_items else 0
open_unrealized = round(sum(_safe_float(x.get("unrealized_pnl_usdt")) for x in open_items), 4)
total_pnl = round(total_realized + open_unrealized, 4)
allocated_margin = round(sum(_safe_float(x.get("margin_usdt")) for x in open_items), 4)
return {
"days": days,
"total": len(items),
"open_count": len(open_items),
"closed_count": len(closed_items),
"win_count": len(wins),
"loss_count": len(losses),
"win_rate": round(len(wins) / len(closed_items) * 100, 2) if closed_items else 0,
"realized_pnl_usdt": total_realized,
"avg_realized_pnl_pct": avg_realized_pct,
"open_unrealized_pnl_usdt": open_unrealized,
"total_pnl_usdt": total_pnl,
"account_equity_usdt": default_account_equity_usdt(),
"account_realized_return_pct": _account_return_pct(total_realized),
"account_unrealized_return_pct": _account_return_pct(open_unrealized),
"account_total_return_pct": _account_return_pct(total_pnl),
"allocated_margin_usdt": allocated_margin,
"available_equity_usdt": round(default_account_equity_usdt() - allocated_margin, 4),
"margin_usdt": default_margin_usdt(),
"leverage": default_leverage(),
"notional_usdt": default_notional_usdt(),
"fee_rate": default_fee_rate(),
"slippage_pct": default_slippage_pct(),
}
def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "") -> dict:
limit = max(1, min(_safe_int(limit, 50), 200))
offset = max(0, _safe_int(offset, 0))
status = str(status or "").strip()
where = ""
params = []
if status in {"open", "closed"}:
where = "WHERE status=%s"
params.append(status)
conn = get_conn()
try:
total = conn.execute(f"SELECT COUNT(*) FROM paper_trades {where}", tuple(params)).fetchone()[0]
rows = conn.execute(
f"""
SELECT * FROM paper_trades
{where}
ORDER BY opened_at DESC, id DESC
LIMIT %s OFFSET %s
""",
tuple(params + [limit, offset]),
).fetchall()
finally:
conn.close()
return {
"items": [_decorate_trade(dict(r)) for r in rows],
"total": int(total or 0),
"limit": limit,
"offset": offset,
"has_more": offset + len(rows) < int(total or 0),
}

View File

@ -38,6 +38,16 @@ DEFAULT_JOBS = [
"description": "推荐价格跟踪",
"sort_order": 20,
},
{
"job_name": "paper-trader",
"command": "paper-trader",
"args": [],
"every_seconds": 180,
"initial_delay": 30,
"lock_group": "paper_trading_write",
"description": "模拟交易账本同步",
"sort_order": 25,
},
{
"job_name": "confirm",
"command": "confirm",

View File

@ -0,0 +1,83 @@
"""Paper trading job entrypoint."""
from __future__ import annotations
import json
from datetime import datetime
import ccxt
from app.db.altcoin_db import init_db, log_cron_run, update_latest_price_cache
from app.db.paper_trading import get_paper_trading_summary, sync_recommendation
from app.db.recommendation_queries import get_active_recommendations_deduped
exchange = ccxt.binance({"enableRateLimit": True})
def run_once(limit: int = 100) -> dict:
init_db()
recs = get_active_recommendations_deduped(actionable_only=True, limit=limit, with_meta=False)
results = []
failed = []
for rec in recs:
symbol = rec.get("symbol")
try:
ticker = exchange.fetch_ticker(symbol)
current_price = float(ticker["last"] or 0)
event_time = datetime.now().isoformat()
update_latest_price_cache(symbol, current_price, updated_at=event_time, source="paper_trader")
result = sync_recommendation(rec, current_price, event_time=event_time)
result.update({"symbol": symbol, "rec_id": rec.get("id"), "current_price": current_price})
results.append(result)
except Exception as exc:
failed.append({"symbol": symbol, "error": str(exc)})
output = {
"status": "completed",
"processed_count": len(results),
"failed_count": len(failed),
"failed": failed,
"results": results,
"summary": get_paper_trading_summary(days=30),
"run_time": datetime.now().isoformat(),
}
print(json.dumps(output, ensure_ascii=False, indent=2, default=str))
return output
def main(limit: int = 100):
started_at = datetime.now()
try:
output = run_once(limit=limit)
except Exception as exc:
finished_at = datetime.now()
log_cron_run(
job_name="模拟交易",
script_name="paper_trader.py",
run_status="error",
result_status="exception",
started_at=started_at.isoformat(),
finished_at=finished_at.isoformat(),
duration_ms=int((finished_at - started_at).total_seconds() * 1000),
summary={},
error_message=str(exc),
)
raise
finished_at = datetime.now()
log_cron_run(
job_name="模拟交易",
script_name="paper_trader.py",
run_status="success",
result_status=output.get("status", "completed"),
started_at=started_at.isoformat(),
finished_at=finished_at.isoformat(),
duration_ms=int((finished_at - started_at).total_seconds() * 1000),
summary={
"processed_count": output.get("processed_count", 0),
"failed_count": output.get("failed_count", 0),
"open_count": output.get("summary", {}).get("open_count", 0),
"closed_count": output.get("summary", {}).get("closed_count", 0),
},
error_message="",
)
return output

View File

@ -30,6 +30,7 @@ from app.db.altcoin_db import (
apply_recommendation_state_transition, log_cron_run,
update_latest_price_cache,
)
from app.db.paper_trading import sync_recommendation as sync_paper_trade
from app.core.pa_engine import (
calc_atr, full_pa_analysis, detect_trend_exhaustion,
analyze_entry_point,
@ -351,7 +352,7 @@ def analyze_tracking_signals(symbol, rec, current_price):
def track_prices():
"""拉取所有active推荐币的实时价格更新盈亏 + 动态跟踪信号"""
recs = get_active_recommendations()
recs = get_active_recommendations(actionable_only=True)
if not recs:
output = {
"status": "no_active",
@ -367,6 +368,21 @@ def track_prices():
for rec in recs:
symbol = rec["symbol"]
try:
if not rec.get("entry_triggered") and rec.get("display_bucket") != "position" and rec.get("execution_status") not in ("holding", "completed"):
results.append({
"symbol": symbol,
"rec_id": rec["id"],
"entry_price": rec["entry_price"],
"current_price": None,
"pnl_pct": None,
"status": "skipped_watch_only",
"action_status": rec.get("action_status"),
"sell_signals": [],
"buy_signals": [],
"exhaustion_severity": "low",
})
print(f" {symbol}: 观察池样本跳过跟踪与止盈判断")
continue
ticker = exchange.fetch_ticker(symbol)
current_price = ticker["last"]
@ -410,6 +426,11 @@ def track_prices():
signals=tracking_signals.get("sell_signals", []) + tracking_signals.get("buy_signals", []),
)
final_action = state_decision.get("action_status", requested_action)
paper_result = sync_paper_trade(
{**rec, **state_decision, "id": rec["id"], "symbol": symbol},
current_price,
event_time=datetime.now().isoformat(),
)
push_trade_action_update(symbol, rec["id"], state_decision, final_action, push_type="entry")
if tracking_signals.get("trailing_stop_activated"):
activation_decision = dict(state_decision)
@ -434,6 +455,7 @@ def track_prices():
"sell_signals": tracking_signals["sell_signals"],
"buy_signals": tracking_signals["buy_signals"],
"exhaustion_severity": tracking_signals.get("exhaustion", {}).get("severity", "low"),
"paper_trade": paper_result,
})
print(f" {symbol}: 入场${rec['entry_price']} → 现在${current_price} "
f"盈亏{tracking_signals['pnl_pct']}% 状态={track_result['status']} "

View File

@ -78,6 +78,13 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
return render_page("system_logs.html", request, active_nav="system_logs")
@router.get("/paper-trading", response_class=HTMLResponse)
async def paper_trading_page(request: Request):
user, redirect = require_page_user(request)
if redirect:
return redirect
return render_page("paper_trading.html", request, active_nav="paper_trading")
@router.get("/strategy", response_class=HTMLResponse)
async def strategy_page(request: Request):
user, redirect = require_page_user(request)

View File

@ -0,0 +1,24 @@
from fastapi import APIRouter, Cookie
from app.db.paper_trading import get_paper_trading_summary, list_paper_trades
from app.web.shared import require_api_user_with_subscription
router = APIRouter()
@router.get("/api/paper-trading/summary")
async def api_paper_trading_summary(days: int = 30, altcoin_session: str = Cookie(default="")):
require_api_user_with_subscription(altcoin_session)
return get_paper_trading_summary(days=days)
@router.get("/api/paper-trading/trades")
async def api_paper_trading_trades(
limit: int = 50,
offset: int = 0,
status: str = "",
altcoin_session: str = Cookie(default=""),
):
require_api_user_with_subscription(altcoin_session)
return list_paper_trades(limit=limit, offset=offset, status=status)

View File

@ -72,12 +72,20 @@ async def api_recommendations(
offset: int = 0,
decision_only: bool = False,
version: str = "",
archive_filter: str = "",
paged: bool = False,
compact: bool = False,
altcoin_session: str = Cookie(default=""),
):
require_api_user_with_subscription(altcoin_session)
return get_all_recommendations(limit, decision_only=decision_only, version=version, offset=offset, with_meta=(paged or compact))
return get_all_recommendations(
limit,
decision_only=decision_only,
version=version,
offset=offset,
with_meta=(paged or compact),
archive_filter=archive_filter,
)
@router.get("/api/recommendations/active")

View File

@ -19,6 +19,7 @@ from app.web.routes_auth import router as auth_router
from app.web.routes_content import build_router as build_content_router
from app.web.routes_market import router as market_router
from app.web.routes_onchain import router as onchain_router
from app.web.routes_paper_trading import router as paper_trading_router
from app.web.routes_pages import build_router as build_pages_router
from app.web.routes_recommendations import router as recommendations_router
from app.web.routes_strategy import router as strategy_router
@ -46,6 +47,7 @@ app.include_router(auth_router)
app.include_router(recommendations_router)
app.include_router(strategy_router)
app.include_router(onchain_router)
app.include_router(paper_trading_router)
app.include_router(market_router)
app.include_router(build_admin_router(templates))
app.include_router(build_content_router(REPO_ROOT))

View File

@ -407,11 +407,11 @@ event_driven:
note: Solana meme主题扩散
meta:
version: 1
last_review: '2026-05-16T15:25:43.236681'
last_reverse_analysis: '2026-05-16T15:27:11.686080'
total_reviews: 53
last_review: '2026-05-16T21:27:46.729074'
last_reverse_analysis: '2026-05-16T21:28:18.838591'
total_reviews: 59
total_rules_learned: 37
iteration_count: 58
iteration_count: 64
strategy_version: v1.7.11
strategy_revision_started_at: '2026-05-09T01:20:00'
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'

View File

@ -19,6 +19,10 @@
.version-select:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(66,98,255,.10); }
.history-version-bar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin: -6px 0 16px; padding: 10px 14px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); background: var(--canvas); }
.history-version-bar label { font-size: 12px; color: var(--stone); font-weight: 600; line-height: 1.4; white-space: nowrap; }
.history-filter-bar { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; margin: 0 0 14px; }
.history-filter-btn { border: 1px solid var(--hairline); background: var(--canvas); color: var(--steel); padding: 7px 12px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; line-height: 1.3; cursor: pointer; transition: .15s; white-space: nowrap; }
.history-filter-btn:hover { color: var(--ink); border-color: var(--hairline-strong); }
.history-filter-btn.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); box-shadow: 0 4px 12px rgba(5,0,56,.10); }
/* ===== DASHBOARD OVERVIEW ===== */
.dashboard-overview { display: flex; flex-direction: column; gap: 14px; margin-bottom: 18px; }
@ -115,6 +119,8 @@
.h-pnl-row .price.h-entry-price { color: var(--blue); }
.h-pnl-row .price.h-exit-price.win { color: var(--green); }
.h-pnl-row .price.h-exit-price.loss { color: var(--red); }
.price.muted { color: var(--stone); }
.h-arrow.neutral { color: var(--stone); }
.h-arrow.win { color: var(--green); }
.h-arrow.loss { color: var(--red); }
.h-duration { color: var(--blue); background: rgba(66,98,255,.06); padding: 3px 8px; border-radius: var(--radius-full); font-weight: 700; }
@ -267,7 +273,7 @@
<div class="controls-row">
<div class="tabs">
<button class="tab-btn active" data-tab="live" onclick="switchTab('live')">实时推荐<span class="count" id="liveCount"></span></button>
<button class="tab-btn" data-tab="history" onclick="switchTab('history')">历史推荐<span class="count" id="histCount"></span></button>
<button class="tab-btn" data-tab="history" onclick="switchTab('history')">推荐归档<span class="count" id="histCount"></span></button>
</div>
</div>
@ -286,6 +292,11 @@
<option value="">全部版本</option>
</select>
</div>
<div class="history-filter-bar" id="historyFilterBar">
<button class="history-filter-btn active" data-filter="" onclick="setHistoryFilter('')">全部</button>
<button class="history-filter-btn" data-filter="executed" onclick="setHistoryFilter('executed')">已执行</button>
<button class="history-filter-btn" data-filter="invalid" onclick="setHistoryFilter('invalid')">失效</button>
</div>
<div id="historyCards"><div class="loading-state"><svg class="spin" width="18" height="18" color="#8e91a0"><use href="#svg-spinner"/></svg> 加载中…</div></div>
</div>
</div>
@ -309,6 +320,7 @@ var historyOffset = 0;
var historyLimit = 24;
var historyHasMore = false;
var historyLoading = false;
var historyArchiveFilter = '';
// ====== VERSIONS ======
async function loadVersions() {
try {
@ -347,6 +359,14 @@ async function onVersionChange() {
else await loadContent(true);
}
function setHistoryFilter(filter) {
historyArchiveFilter = filter || '';
document.querySelectorAll('#historyFilterBar .history-filter-btn').forEach(function(btn){
btn.classList.toggle('active', (btn.dataset.filter || '') === historyArchiveFilter);
});
loadHistoryRecommendations(true);
}
// ====== LOAD ======
async function switchTab(tab) {
curTab = tab;
@ -854,18 +874,30 @@ function renderKlineChart(symbol, candles, entryPrice, stopLoss, tp1, recTime, t
// ====== HISTORY ======
function historyOutcome(r) {
var status = (r && r.status) || '';
var pnl = Number((r && r.pnl_pct) || 0);
var maxPnl = Number((r && r.max_pnl_pct) || 0);
var maxDd = Number((r && r.max_drawdown_pct) || 0);
var hitFailure = status === 'stopped_out' || pnl <= -3 || maxDd <= -5;
if (hitFailure) {
return { resolved: true, type: 'failure', pnl: pnl, label: '风险边界' };
var execution = (r && r.execution_status) || '';
var bucket = (r && r.display_bucket) || '';
var triggered = !!(r && Number(r.entry_triggered || 0));
var paper = r && r.paper_trade ? r.paper_trade : null;
if (paper && paper.status === 'closed') {
var exitReason = String(paper.exit_reason || '').toLowerCase();
if (exitReason === 'stop_loss' || exitReason === 'sl' || exitReason === 'stopped_out') {
return { resolved: true, type: 'executed_failed', label: '模拟交易止损', detail: '已进入模拟交易并触发止损' };
}
return { resolved: true, type: 'executed_success', label: '模拟交易兑现', detail: '已进入模拟交易并完成退出' };
}
var hitSuccess = status === 'hit_tp1' || status === 'hit_tp2' || maxPnl >= 5;
if (hitSuccess) {
return { resolved: true, type: 'success', pnl: maxPnl || pnl, label: '阶段兑现' };
if (paper && paper.status === 'open') {
return { resolved: true, type: 'executed_open', label: '模拟交易持有', detail: '已进入模拟交易,仍在持仓中' };
}
return { resolved: false, type: 'pending', pnl: pnl, label: '跟踪中' };
if (status === 'hit_tp1' || status === 'hit_tp2' || execution === 'completed') {
return { resolved: true, type: 'executed_success', label: '执行后兑现', detail: '已进入模拟/持仓口径验证' };
}
if (status === 'stopped_out') {
return { resolved: true, type: 'executed_failed', label: '执行后止损', detail: '执行样本触发风险边界' };
}
if (status === 'expired' || status === 'invalid' || status === 'archived' || execution === 'invalid' || bucket === 'history') {
return { resolved: true, type: triggered ? 'executed_invalid' : 'not_executed', label: triggered ? '执行后失效' : '未执行失效', detail: triggered ? '曾进入执行态,后续失效' : '推荐/观察后未形成真实交易' };
}
return { resolved: false, type: 'pending', label: '仍在跟踪', detail: '尚未归档' };
}
function isResolvedHistory(r) {
return historyOutcome(r).resolved;
@ -881,23 +913,22 @@ async function loadHistoryRecommendations(reset) {
try {
var offset = reset ? 0 : historyOffset;
var pageSize = historyLimit;
var url = API+'/api/recommendations?limit='+pageSize+'&offset='+offset+'&decision_only=true&compact=true';
var url = API+'/api/recommendations?limit='+pageSize+'&offset='+offset+'&decision_only=true&compact=true&archive_filter='+encodeURIComponent(historyArchiveFilter || '');
if (currentVersion) url += '&version=' + encodeURIComponent(currentVersion);
var resp = await fetch(url);
var page = await resp.json();
var summary = page.summary || {};
var totalCount = Number(page.total || summary.total || 0);
var successCount = Number(summary.success_count || 0);
var failureCount = Number(summary.failure_count || 0);
var totalPnl = Number(summary.total_pnl || 0);
var bestPnl = Number(summary.best_pnl || 0);
var avgSl = Number(summary.avg_failure_pnl || 0);
var executedCount = Number(summary.executed_count || 0);
var completedCount = Number(summary.completed_count || 0);
var invalidCount = Number(summary.invalid_count || 0);
var notExecutedCount = Number(summary.not_executed_count || 0);
$('histCount').textContent = totalCount ? ' ' + totalCount : '';
$('historyStats').innerHTML =
'<div class="hstat"><div class="num" style="color:var(--green)">'+successCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--green)"><use href="#svg-target"/></svg> 已兑现样本</div><div class="sub">全部历史</div></div>'+
'<div class="hstat"><div class="num" style="color:'+(totalPnl>=0?'var(--green)':'var(--red)')+'">'+(totalPnl>=0?'+':'')+totalPnl.toFixed(1)+'%</div><div class="lbl"><svg width="14" height="14" color="'+(totalPnl>=0?'var(--green)':'var(--red)')+'"><use href="#svg-trendup"/></svg> 累计表现</div><div class="sub">全部历史</div></div>'+
'<div class="hstat"><div class="num" style="color:var(--green)">+'+bestPnl.toFixed(1)+'%</div><div class="lbl"><svg width="14" height="14" color="var(--green)"><use href="#svg-star"/></svg> 最大单笔表现</div><div class="sub">全部历史</div></div>'+
'<div class="hstat"><div class="num" style="color:var(--red)">'+avgSl.toFixed(1)+'%</div><div class="lbl"><svg width="14" height="14" color="var(--red)"><use href="#svg-shield"/></svg> 风险边界失效</div><div class="sub">'+failureCount+'次 · 平均</div></div>';
'<div class="hstat"><div class="num" style="color:var(--blue)">'+totalCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--blue)"><use href="#svg-target"/></svg> 归档信号</div><div class="sub">推荐/观察历史</div></div>'+
'<div class="hstat"><div class="num" style="color:var(--green)">'+executedCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--green)"><use href="#svg-trendup"/></svg> 进入执行</div><div class="sub">收益见模拟交易</div></div>'+
'<div class="hstat"><div class="num" style="color:var(--yellow-dark)">'+notExecutedCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--yellow-dark)"><use href="#svg-star"/></svg> 未执行归档</div><div class="sub">观察/等回踩失效</div></div>'+
'<div class="hstat"><div class="num" style="color:var(--red)">'+invalidCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--red)"><use href="#svg-shield"/></svg> 信号失效</div><div class="sub">含过期/风控失效</div></div>';
var items = Array.isArray(page.items) ? page.items : [];
var completed = items.filter(isResolvedHistory);
if (reset) {
@ -908,25 +939,27 @@ async function loadHistoryRecommendations(reset) {
historyOffset += completed.length;
}
historyHasMore = !!page.has_more;
if(!historyItems.length){ $('historyCards').innerHTML='<div class="empty-state"><p>暂无已完成交易记录<br>机会完成兑现或风险边界失效后会出现在这里</p></div>'; return; }
if(!historyItems.length){ $('historyCards').innerHTML='<div class="empty-state"><p>暂无归档推荐<br>推荐过期、失效或完成后会出现在这里</p></div>'; return; }
var cardsHtml = historyItems.map(function(r,idx) {
var base = (r.symbol||'').replace('/USDT',''), outcome = historyOutcome(r), pnl = outcome.pnl, win = pnl>0;
var pnlSign = pnl>0?'+':'', pnlCls = win?'pos':pnl<0?'neg':'zero';
var exitP = (r.status==='stopped_out'||Number(r.pnl_pct||0)<0) ? (r.current_price||r.stop_loss||0) : (r.tp1||r.current_price||0);
var entryP = r.entry_price||0;
var base = (r.symbol||'').replace('/USDT',''), outcome = historyOutcome(r);
var paper = r.paper_trade || null;
var hasPaper = !!(paper && paper.id);
var exitP = hasPaper ? Number(paper.exit_price || 0) : 0;
var entryP = hasPaper ? Number(paper.entry_price || 0) : 0;
function fmtN(n) { return fmtPrice(n, priceDecimals(r.current_price || entryP || exitP || n)); }
var statusLabel = outcome.type === 'failure' ? '风险边界失效' : '阶段兑现';
var isWin = Number(pnl || 0) > 0;
var resultCls = isWin ? 'win' : 'loss';
var maxPnl = Number(r.max_pnl_pct || pnl || 0);
var maxPnlSign = maxPnl > 0 ? '+' : '';
var maxPnlCls = maxPnl > 0 ? 'win' : (maxPnl < 0 ? 'loss' : '');
var resultCls = (outcome.type === 'executed_success') ? 'win' : ((outcome.type === 'executed_failed') ? 'loss' : 'neutral');
var statusColorCls = resultCls === 'win' ? 'pos' : (resultCls === 'loss' ? 'neg' : 'zero');
var afterMove = hasPaper && entryP && exitP ? ((exitP / entryP - 1) * 100) : 0;
var afterMoveSign = afterMove > 0 ? '+' : '';
var maxPnl = Number(r.max_pnl_pct || 0);
var maxDd = Number(r.max_drawdown_pct || 0);
var exitMode = outcome.type === 'failure' ? '风险边界' : ((r.action_status==='跟踪止盈') ? '跟踪止盈' : '阶段兑现');
var isRiskExit = outcome.type === 'failure';
var hEntryTime = r.rec_time||'', hTpTime = (!isRiskExit && (r.status==='hit_tp1'||r.status==='hit_tp2'||Number(r.max_pnl_pct||0)>=5))?(r.hit_tp1_time||r.last_track_time||''):'';
var hSlTime = isRiskExit ? (r.stopped_out_time||r.last_track_time||r.expired_time||'') : '';
var hEntryPrice = r.entry_price||0, hSl = isRiskExit ? exitP : (r.stop_loss||0), hTp = isRiskExit ? 0 : (r.tp1||0), hid = 'hkline'+idx;
var exitMode = outcome.label;
var isPaperClosed = hasPaper && paper.status === 'closed';
var isPaperStop = isPaperClosed && /stop|sl|stopped/i.test(String(paper.exit_reason || ''));
var hEntryTime = hasPaper ? (paper.opened_at||r.rec_time||'') : '';
var hTpTime = isPaperClosed && !isPaperStop ? (paper.closed_at||'') : '';
var hSlTime = isPaperClosed && isPaperStop ? (paper.closed_at||'') : '';
var hEntryPrice = hasPaper ? entryP : 0, hSl = isPaperClosed ? (isPaperStop ? exitP : Number(paper.stop_loss || 0)) : 0, hTp = isPaperClosed ? (isPaperStop ? 0 : (Number(paper.tp1 || 0) || exitP)) : 0, hid = 'hkline'+idx;
var score = r.rec_score||0;
function scoreTier(s) {
if(s>=80) return{label:'强势异动',cls:'tier-strong'}; if(s>=65) return{label:'值得关注',cls:'tier-good'};
@ -938,10 +971,12 @@ async function loadHistoryRecommendations(reset) {
var sigs = Array.isArray(r.signals)?r.signals:[];
var sigHtml = sigs.slice(0,4).map(function(s){ return '<span class=\"sig info\">'+cleanDisplayText(s).replace(/^(\\d+H|\\d+m|日线|周线)\\s*/,'').slice(0,12)+'</span>'; }).join('');
var duration = daysBetween(r.rec_time, r.last_track_time||r.hit_tp1_time||r.stopped_out_time);
var execText = hasPaper ? (paper.status === 'closed' ? '已模拟交易完成' : '模拟交易持有中') : (Number(r.entry_triggered || 0) ? '已触发执行' : '未执行');
var signalStateText = hasPaper ? '已执行归档' : (historyArchiveFilter === 'invalid' ? '失效归档' : '未执行归档');
return '<div class=\"card\">'+
'<div class=\"card-bar\"><div class=\"coin-left\"><div class=\"coin-icon\">'+base.slice(0,2).toUpperCase()+'</div><div><span class=\"coin-symbol\">'+base+'</span></div></div><span class=\"hist-pnl-badge '+pnlCls+'\"><span class=\"pnl-num\">'+pnlSign+pnl.toFixed(1)+'</span><span class=\"pnl-unit\">%</span></span></div>'+
'<div class=\"h-pnl-row\"><span class=\"price h-entry-price\">$'+fmtN(entryP)+'</span><span class=\"h-arrow '+resultCls+'\">&rarr;</span><span class=\"price h-exit-price '+resultCls+'\">$'+fmtN(exitP)+'</span><span class=\"hist-score-pill '+scoreCls+'\">评分 '+score+' · '+st.label+'</span><span class=\"h-duration\">'+duration+'</span></div>'+
'<div class=\"hist-metric-row\"><div class=\"hist-metric\"><span class=\"hm-label\">最大表现</span><span class=\"hm-val '+maxPnlCls+'\">'+maxPnlSign+maxPnl.toFixed(1)+'%</span></div><div class=\"hist-metric\"><span class=\"hm-label\">最大回撤</span><span class=\"hm-val loss\">'+maxDd.toFixed(1)+'%</span></div><div class=\"hist-metric\"><span class=\"hm-label\">退出方式</span><span class=\"hm-val blue\">'+exitMode+'</span></div></div>'+
'<div class=\"card-bar\"><div class=\"coin-left\"><div class=\"coin-icon\">'+base.slice(0,2).toUpperCase()+'</div><div><span class=\"coin-symbol\">'+base+'</span></div></div><span class=\"hist-result-badge '+resultCls+'\">'+outcome.label+'</span></div>'+
'<div class=\"h-pnl-row\">'+(hasPaper ? '<span class=\"price h-entry-price\">$'+fmtN(entryP)+'</span><span class=\"h-arrow neutral\">&rarr;</span><span class=\"price h-exit-price '+(paper.status === 'closed' ? resultCls : 'muted')+'\">'+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')+'</span>' : '<span class=\"price h-entry-price muted\">未执行</span><span class=\"h-arrow neutral\">&rarr;</span><span class=\"price h-exit-price muted\">失效/归档</span>')+'<span class=\"hist-score-pill '+scoreCls+'\">评分 '+score+' · '+st.label+'</span><span class=\"h-duration\">'+duration+'</span></div>'+
'<div class=\"hist-metric-row\"><div class=\"hist-metric\"><span class=\"hm-label\">归档状态</span><span class=\"hm-val '+(hasPaper ? 'win' : 'blue')+'\">'+signalStateText+'</span></div><div class=\"hist-metric\"><span class=\"hm-label\">执行状态</span><span class=\"hm-val '+(Number(r.entry_triggered||0)?'win':'blue')+'\">'+execText+'</span></div><div class=\"hist-metric\"><span class=\"hm-label\">归档原因</span><span class=\"hm-val blue\">'+exitMode+'</span></div></div>'+
'<div class=\"kline-wrap\" id=\"wrap_'+hid+'\"><div class=\"kline-int-bar\"><button class=\"kline-int-btn\" data-int=\"15m\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">15m</button><button class=\"kline-int-btn active\" data-int=\"1h\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">1H</button><button class=\"kline-int-btn\" data-int=\"4h\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">4H</button><button class=\"kline-int-btn\" data-int=\"1d\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">1D</button></div><div class=\"kline-container loading\" id=\"'+hid+'\" data-symbol=\"'+(r.symbol||'')+'\" data-entry-price=\"'+hEntryPrice+'\" data-stop-loss=\"'+hSl+'\" data-tp1=\"'+hTp+'\" data-rec-time=\"'+hEntryTime+'\" data-tp1-time=\"'+hTpTime+'\" data-sl-time=\"'+hSlTime+'\" data-ref-price=\"'+(r.current_price||hEntryPrice||hTp||hSl||0)+'\" data-status=\"'+(r.status||'')+'\" ><div class=\"chart-loading\"><svg class=\"spin\" width=\"16\" height=\"16\" color=\"#8e91a0\"><use href=\"#svg-spinner\"/></svg></div></div></div>'+
(sigHtml?'<div class=\"signals-row\">'+sigHtml+'</div>':'')+
'<div class=\"card-footer hist-footer\"><span>'+fmtTime(r.rec_time)+'</span><span class=\"card-ver\">'+(r.strategy_version||'')+'</span></div></div>';

View File

@ -158,6 +158,7 @@ a { color: inherit; text-decoration: none; }
<symbol id="svg-iterate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></symbol>
<symbol id="svg-sentiment" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></symbol>
<symbol id="svg-onchain" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="7" r="3"/><circle cx="18" cy="7" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.6 8.8 10.7 15"/><path d="M15.4 8.8 13.3 15"/><path d="M9 7h6"/></symbol>
<symbol id="svg-paper" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19V5"/><path d="M4 19h16"/><path d="M7 15l3-4 3 2 5-7"/><path d="M17 6h1v1"/></symbol>
<symbol id="svg-subscribe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></symbol>
<symbol id="svg-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M5.3 20h13.4c1.1 0 2-.9 2-2 0-3.3-2.7-6-6-6H9.3c-3.3 0-6 2.7-6 6 0 1.1.9 2 2 2z"/></symbol>
<symbol id="svg-referral" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><polyline points="17 11 19 13 23 9"/></symbol>
@ -175,6 +176,7 @@ a { color: inherit; text-decoration: none; }
<a class="sidebar-link {% if active_nav == 'market' %}active{% endif %}" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
<a class="sidebar-link {% if active_nav == 'sentiment' %}active{% endif %}" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link {% if active_nav == 'onchain' %}active{% endif %}" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
<a class="sidebar-link {% if active_nav == 'paper_trading' %}active{% endif %}" href="/paper-trading"><svg class="link-icon"><use href="#svg-paper"/></svg>模拟交易</a>
<a class="sidebar-link {% if active_nav == 'subscription' %}active{% endif %}" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
<a class="sidebar-link {% if active_nav == 'referral' %}active{% endif %}" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
@ -252,7 +254,15 @@ async function loadUser() {
function toggleUserMenu() { $('userDropdown').classList.toggle('show'); }
document.addEventListener('click', function(e) { if (!e.target.closest('.sidebar-user') && !e.target.closest('.user-dropdown')) $('userDropdown').classList.remove('show'); });
function showChangePwd() { $('userDropdown').classList.remove('show'); $('pwdModal').classList.add('show'); }
function showChangePwd() {
$('userDropdown').classList.remove('show');
$('pwdModal').classList.add('show');
$('oldPwd').value = '';
$('newPwd').value = '';
$('cfmPwd').value = '';
$('pwdMsg').textContent = '';
$('pwdMsg').className = 'modal-msg';
}
function closePwdModal() { $('pwdModal').classList.remove('show'); $('pwdMsg').textContent=''; $('pwdMsg').className='modal-msg'; }
async function changePwd() {
@ -268,7 +278,7 @@ async function changePwd() {
async function doLogout() {
try{ await fetch(API+'/api/auth/logout',{method:'POST'}); }catch(e){}
window.location.href='/auth';
window.location.href='/';
}
// Mobile sidebar

View File

@ -127,17 +127,11 @@ h2 { font-size:26px; font-weight:900; margin:0 0 8px; color:var(--ink); }
{% block extra_script %}
<script>
var API = '';
var $ = function(id){ return document.getElementById(id); };
var state = { data:null };
function esc(v){ return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];}); }
function fmtTime(t){ if(!t)return '--'; var d=new Date(t); return (d.getMonth()+1)+'/'+d.getDate()+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2); }
function badge(status){ var cls=status==='release'||status==='active'?'release':status==='gray'?'gray':status==='rejected'?'reject':'hold'; var txt={release:'正式发布',gray:'灰度观察',hold:'只研究不发布',candidate:'候选研究',active:'正式生效',rejected:'已淘汰',blocked:'发布阻断',unknown:'旧日志',dirty_history:'污染历史'}[status]||status||'研究中'; return '<span class="badge '+cls+'">'+esc(txt)+'</span>'; }
function switchTab(tab){ document.querySelectorAll('.tab').forEach(function(x){x.classList.toggle('active',x.dataset.tab===tab);}); document.querySelectorAll('.panel').forEach(function(x){x.classList.remove('active');}); $('panel-'+tab).classList.add('active'); }
async function loadUser(){ try{ var r=await fetch(API+'/api/auth/me'); if(!r.ok)return; var d=await r.json(); var email=(d.user&&d.user.email)||''; if(!email)return; $('userInitial').textContent=email.charAt(0).toUpperCase(); $('userEmailShort').textContent=email.length>14?email.slice(0,12)+'…':email; $('ddEmail').textContent=email; }catch(e){} }
function toggleUserMenu(){ $('userDropdown').classList.toggle('show'); }
document.addEventListener('click',function(e){ if(!e.target.closest('.sidebar-user')&&!e.target.closest('.user-dropdown')) $('userDropdown').classList.remove('show'); });
async function doLogout(){ await fetch(API+'/api/auth/logout',{method:'POST'}); location.href='/auth'; }
async function refreshCandidates(){ var old=document.querySelector('.toolbar .btn'); try{ old.textContent='刷新中…'; await fetch(API+'/api/strategy/candidates/refresh',{method:'POST'}); await loadAll(); }catch(e){ alert('刷新失败'); } finally{ old.textContent='刷新候选评分'; } }
async function loadAll(){ try{ var r=await fetch(API+'/api/strategy/lifecycle?days=60'); var d=await r.json(); state.data=d; renderAll(d); }catch(e){ $('timeline').innerHTML='<div class="empty">加载失败</div>'; } }
function renderAll(d){ renderKpis(d); renderGate(d); renderUserReport(d); renderTimeline(d.logs||[]); renderCandidates(d.candidates||[], d.dry_run||{}); renderDryRun(d.dry_run||{}); renderFailures(d); renderVersions(((d.summary||{}).version_stats)||[]); }
@ -183,6 +177,6 @@ function renderCandidates(items,dry){ if(!items.length){$('candidates').innerHTM
function renderDryRun(dry){ var items=dry.evaluated_candidates||[]; if(!items.length){$('dryrun').innerHTML='<div class="empty">暂无待验证规律可评估</div>';return;} $('dryrun').innerHTML='<div class="gate-text">当前版本 '+esc(dry.current_version||'--')+';干净样本起点 '+esc(dry.clean_started_at||'未设置')+';干净复盘样本 '+esc(dry.review_sample_count||0)+';污染历史候选 '+esc(dry.dirty_history_candidate_count||0)+';可灰度 '+esc(dry.gray_ready_count||0)+';是否发布:'+(dry.would_bump_version?'是':'否')+'。</div><div class="gate-text"><b>灰度标准:</b>'+esc((dry.gate_policy&&dry.gate_policy.gray)||'--')+'</div><table class="table"><thead><tr><th>预演结论</th><th>规律</th><th>样本</th><th>成功/失败</th><th>可信度</th><th>平均表现</th><th>原因</th></tr></thead><tbody>'+items.map(function(x){return '<tr><td>'+badge(x.dry_run_status||'candidate')+'</td><td class="rule-name">'+esc(x.rule_description||x.signal_name||'--')+'</td><td>'+esc(x.sample_size||0)+'</td><td>'+esc(x.success_count||0)+' / '+esc(x.fail_count||0)+'</td><td class="score">'+esc(x.confidence_score||0)+'</td><td>'+esc(x.avg_pnl||0)+'</td><td class="reason">'+esc(x.gate_reason||'--')+'</td></tr>';}).join('')+'</tbody></table>'; }
function renderFailures(d){ var fs=(d.overview&&d.overview.failure_type_counts)||[]; $('failureSummary').innerHTML=fs.length?fs.map(function(f){return '<span class="failure-chip">'+esc(f.type)+' · '+esc(f.count)+'</span>';}).join(''):'<div class="empty">暂无失败模式</div>'; var items=d.failures||[]; $('failures').innerHTML=items.length?items.slice(0,30).map(function(f){return '<div class="item warn"><b>'+esc(f.symbol||'--')+'</b> · '+esc(f.failure_type||'未分类')+' · '+esc((f.failure_reason||'').slice(0,90))+' · PnL '+esc(f.pnl_pct||0)+'</div>';}).join(''):'<div class="empty">暂无失败样本</div>'; }
function renderVersions(items){ if(!items.length){$('versions').innerHTML='<div class="empty">暂无版本表现</div>';return;} $('versions').innerHTML='<table class="table"><thead><tr><th>版本</th><th>推荐数</th><th>成功</th><th>失败</th><th>待观察</th><th>成功率</th><th>均值收益</th></tr></thead><tbody>'+items.map(function(v){return '<tr><td class="rule-name">'+esc(v.strategy_version)+'</td><td>'+esc(v.recommendation_count)+'</td><td>'+esc(v.success_count)+'</td><td>'+esc(v.failed_count)+'</td><td>'+esc(v.pending_count)+'</td><td class="score">'+esc(v.success_rate_pct)+'</td><td>'+esc(v.avg_pnl_pct)+'</td></tr>';}).join('')+'</tbody></table>'; }
loadUser(); loadAll();
loadAll();
</script>
{% endblock %}

74
static/paper_trading.html Normal file
View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}AlphaX Agent Crypto — 模拟交易{% endblock %}
{% block extra_head_css %}
<style>
.shell{width:min(100% - 40px,1280px);margin:0 auto;padding:24px 0 48px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:16px}.page-head h1{font-size:28px;font-weight:950;color:var(--ink);letter-spacing:-.7px}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:860px}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn,.select{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.btn{cursor:pointer}.kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:14px}.kpi{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);padding:14px;min-width:0}.kpi span{display:block;color:var(--stone);font-size:11px;font-weight:900}.kpi b{display:block;margin-top:7px;color:var(--ink);font-size:23px;line-height:1;font-weight:950;letter-spacing:-.5px}.kpi small{display:block;margin-top:7px;color:var(--stone);font-size:11px;font-weight:800}.kpi b.green{color:var(--green)}.kpi b.red{color:var(--red)}.kpi b.blue{color:var(--blue)}.note{border:1px solid rgba(66,98,255,.14);background:rgba(66,98,255,.045);border-radius:var(--radius-md);padding:11px 12px;color:var(--slate);font-size:12px;line-height:1.55;margin-bottom:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:950;color:var(--ink)}.panel-note{font-size:11px;color:var(--stone);font-weight:850}.table-wrap{overflow:auto}.table{width:100%;border-collapse:collapse;min-width:1180px}.table th,.table td{padding:11px 10px;border-bottom:1px solid var(--hairline-soft);text-align:left;font-size:12px;vertical-align:middle}.table th{font-size:11px;color:var(--stone);font-weight:900;background:var(--surface)}.sym{font-weight:950;color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.badge{display:inline-flex;align-items:center;height:24px;border-radius:999px;padding:0 8px;font-size:11px;font-weight:900;border:1px solid var(--hairline-soft);background:var(--surface);color:var(--slate);white-space:nowrap}.badge.open{background:rgba(66,98,255,.08);border-color:rgba(66,98,255,.18);color:var(--blue)}.badge.closed{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:850}.pos{color:var(--green)}.neg{color:var(--red)}.muted{color:var(--stone)}.riskline{display:grid;gap:3px}.pagination{display:flex;justify-content:center;align-items:center;gap:12px;padding:14px;color:var(--stone);font-size:12px}.pagination button{height:34px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:12px;font-weight:850;cursor:pointer}.pagination button:disabled{opacity:.45;cursor:default}.empty,.loading{text-align:center;padding:34px 14px;color:var(--stone);font-size:13px}@media(max-width:1100px){.kpis{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:700px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.shell{width:min(100% - 24px,1280px)}}@media(max-width:520px){.kpis{grid-template-columns:1fr}.page-head h1{font-size:22px}}
</style>
{% endblock %}
{% block content %}
<div class="shell">
<div class="page-head">
<div>
<h1>模拟交易</h1>
<p>这里展示的是 paper trading 账本:只有系统把可买信号模拟成交后,才会进入收益统计。推荐历史和观察池不会直接产生收益率。</p>
</div>
<div class="actions">
<select class="select" id="statusFilter" onchange="loadTrades(0)">
<option value="">全部</option>
<option value="open">持仓中</option>
<option value="closed">已平仓</option>
</select>
<button class="btn" onclick="loadAll()">刷新</button>
</div>
</div>
<div class="note" id="paperNote">模拟成交默认使用 20,000U 本金、每笔 5,000U 名义仓位、5x 杠杆、1,000U 保证金,用来验证策略真实交易口径。它不代表真实账户持仓,也不会反写推荐收益。</div>
<div class="kpis" id="kpis"><div class="kpi"><span>状态</span><b>加载中</b></div></div>
<section class="panel">
<div class="panel-head"><div class="panel-title">交易账本</div><div class="panel-note" id="pageInfo">--</div></div>
<div class="table-wrap">
<table class="table">
<thead><tr><th>币种</th><th>状态</th><th>仓位</th><th>开仓</th><th>止盈 / 止损</th><th>最新 / 平仓</th><th>价格收益</th><th>账户收益</th><th>退出原因</th><th>来源</th></tr></thead>
<tbody id="tradeRows"><tr><td colspan="10" class="loading">加载中...</td></tr></tbody>
</table>
</div>
<div class="pagination" id="pager"></div>
</section>
</div>
{% endblock %}
{% block extra_script %}
<script>
var LIMIT=50,offset=0,total=0;
function $(id){return document.getElementById(id)}
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
function fmt(v,d){v=Number(v||0);return v.toLocaleString(undefined,{maximumFractionDigits:d==null?4:d,minimumFractionDigits:0})}
function pct(v){v=Number(v||0);var cls=v>0?'pos':v<0?'neg':'';return '<span class="mono '+cls+'">'+(v>0?'+':'')+fmt(v,2)+'%</span>'}
function money(v){v=Number(v||0);var cls=v>0?'pos':v<0?'neg':'';return '<span class="mono '+cls+'">'+(v>0?'+':'')+fmt(v,2)+' USDT</span>'}
function time(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime()))return String(t).slice(0,16).replace('T',' ');return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')}
async function api(url){var r=await fetch(url);var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
async function loadAll(){await Promise.all([loadSummary(),loadTrades(offset)])}
async function loadSummary(){try{var d=await api('/api/paper-trading/summary?days=30');$('paperNote').textContent='模拟成交使用 '+fmt(d.account_equity_usdt||20000,0)+'U 本金、每笔 '+fmt(d.notional_usdt||5000,0)+'U 名义仓位、'+fmt(d.leverage||5,1)+'x 杠杆、'+fmt(d.margin_usdt||1000,0)+'U 保证金。账户收益率按已实现/浮动盈亏除以模拟本金计算。';$('kpis').innerHTML=[
card('模拟本金',fmt(d.account_equity_usdt||0,0)+'U','blue','账户收益率基准'),
card('单笔仓位',fmt(d.notional_usdt||0,0)+'U','','名义仓位'),
card('杠杆 / 保证金',fmt(d.leverage||1,1)+'x','',fmt(d.margin_usdt||0,0)+'U 保证金'),
card('持仓 / 已平仓',(d.open_count||0)+' / '+(d.closed_count||0),''),
card('账户总收益率',fmt(d.account_total_return_pct||0,2)+'%',(d.account_total_return_pct||0)>=0?'green':'red',money(d.total_pnl_usdt||0).replace(/<[^>]+>/g,'')),
card('胜率',(d.win_rate||0)+'%','green','已平仓样本')
].join('')}catch(e){$('kpis').innerHTML='<div class="kpi"><span>状态</span><b>加载失败</b></div>'}}
function card(label,value,cls,sub){return '<div class="kpi"><span>'+esc(label)+'</span><b class="'+esc(cls||'')+'">'+esc(value)+'</b>'+(sub?'<small>'+esc(sub)+'</small>':'')+'</div>'}
async function loadTrades(nextOffset){offset=Math.max(0,nextOffset||0);$('tradeRows').innerHTML='<tr><td colspan="10" class="loading">加载中...</td></tr>';try{var s=$('statusFilter').value;var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+offset+'&status='+encodeURIComponent(s));total=d.total||0;renderTrades(d.items||[]);renderPager()}catch(e){$('tradeRows').innerHTML='<tr><td colspan="10" class="empty">'+esc(e.message)+'</td></tr>'}}
function renderTrades(items){if(!items.length){$('tradeRows').innerHTML='<tr><td colspan="10" class="empty">暂无模拟交易</td></tr>';return}$('tradeRows').innerHTML=items.map(function(x){var st=x.status==='open'?'持仓中':'已平仓';var latest=x.status==='open'?x.current_price:x.exit_price;var pnlUsdt=x.status==='closed'?x.realized_pnl_usdt:x.unrealized_pnl_usdt;return '<tr>'+
'<td><div class="sym">'+esc(x.symbol)+'</div><div class="muted">#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'</div></td>'+
'<td><span class="badge '+esc(x.status)+'">'+st+'</span></td>'+
'<td><div class="mono">'+fmt(x.notional_usdt,0)+'U</div><div class="muted">'+fmt(x.leverage,1)+'x · 保证金 '+fmt(x.margin_usdt,0)+'U</div></td>'+
'<td><div class="mono">$'+fmt(x.entry_price,6)+'</div><div class="muted">'+time(x.opened_at)+'</div></td>'+
'<td><div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span></div></td>'+
'<td><div class="mono">$'+fmt(latest,6)+'</div><div class="muted">'+(x.closed_at?time(x.closed_at):'最新')+'</div></td>'+
'<td>'+pct(x.status==='closed'?x.realized_pnl_pct:x.pnl_pct)+'</td>'+
'<td><div>'+money(pnlUsdt)+'</div><div class="muted">账户 '+(x.account_return_pct>0?'+':'')+fmt(x.account_return_pct,2)+'% · 保证金 '+(x.margin_roi_pct>0?'+':'')+fmt(x.margin_roi_pct,2)+'%</div></td>'+
'<td>'+esc(x.exit_reason||'--')+'</td>'+
'<td><div>'+esc(x.source_status||'--')+'</div><div class="muted">'+esc(x.strategy_version||'')+'</div></td>'+
'</tr>'}).join('')}
function renderPager(){var page=Math.floor(offset/LIMIT)+1,totalPages=Math.max(1,Math.ceil(total/LIMIT));$('pageInfo').textContent='第 '+page+' / '+totalPages+' 页 · 共 '+total+' 条';$('pager').innerHTML='<button '+(offset===0?'disabled':'')+' onclick="loadTrades('+(offset-LIMIT)+')">上一页</button><span>第 '+page+' / '+totalPages+' 页</span><button '+((offset+LIMIT>=total)?'disabled':'')+' onclick="loadTrades('+(offset+LIMIT)+')">下一页</button>'}
loadAll();
</script>
{% endblock %}

View File

@ -153,61 +153,6 @@
{% endblock %}
{% block extra_script %}
<script>
var API = '';
// ====== USER ======
var currentUser = null;
var $ = function(id){ return document.getElementById(id); };
async function loadUser() {
try {
var resp = await fetch(API + '/api/auth/me');
if (!resp.ok) return;
var data = await resp.json();
currentUser = data.user;
var email = currentUser.email || '--';
$('userInitial').textContent = email.charAt(0).toUpperCase();
$('userEmailShort').textContent = email.length > 14 ? email.slice(0,12) + '…' : email;
$('ddEmail').textContent = email;
} catch(e) {}
}
function toggleUserMenu() {
$('userDropdown').classList.toggle('show');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.sidebar-user') && !e.target.closest('.user-dropdown')) $('userDropdown').classList.remove('show');
});
function showChangePwd() {
$('userDropdown').classList.remove('show');
$('pwdModal').classList.add('show');
$('oldPwd').value = ''; $('newPwd').value = ''; $('cfmPwd').value = ''; $('pwdMsg').textContent = '';
}
function closePwdModal() { $('pwdModal').classList.remove('show'); }
async function changePwd() {
var old = $('oldPwd').value, nw = $('newPwd').value, cf = $('cfmPwd').value;
if (!old || !nw) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '请填写所有字段'; return; }
if (nw.length < 8) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '新密码至少 8 位'; return; }
if (nw !== cf) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '两次密码不一致'; return; }
try {
var r = await fetch(API + '/api/auth/change-password', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({old_password: old, new_password: nw})
});
var d = await r.json();
if (!r.ok) throw new Error(d.detail || '修改失败');
$('pwdMsg').className = 'modal-msg ok'; $('pwdMsg').textContent = d.message || '修改成功';
setTimeout(closePwdModal, 1200);
} catch(e) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = e.message; }
}
async function doLogout() {
await fetch(API + '/api/auth/logout', { method: 'POST' });
window.location.href = '/auth';
}
// ====== FEED ======
function ageStr(h) {
@ -361,7 +306,6 @@ async function loadFeed() {
}
}
loadUser();
loadFeed();
// Auto-refresh every 5 minutes
setInterval(loadFeed, 300000);

View File

@ -123,10 +123,6 @@
{% endblock %}
{% block extra_script %}
<script>
var $ = function(id){ return document.getElementById(id); };
// ====== USER ======
var currentUser = null;
function readablePlanName(code) {
var names = {
free_trial_1m: '免费体验',
@ -137,55 +133,6 @@ function readablePlanName(code) {
return names[code] || code || '未开通';
}
async function loadUser() {
try {
var resp = await fetch('/api/auth/me');
if (!resp.ok) return;
var data = await resp.json();
currentUser = data.user;
var email = currentUser.email || '--';
$('userInitial').textContent = email.charAt(0).toUpperCase();
$('userEmailShort').textContent = email.length > 14 ? email.slice(0,12) + '…' : email;
$('ddEmail').textContent = email;
} catch(e) {}
}
function toggleUserMenu() {
$('userDropdown').classList.toggle('show');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.sidebar-user') && !e.target.closest('.user-dropdown')) $('userDropdown').classList.remove('show');
});
function showChangePwd() {
$('userDropdown').classList.remove('show');
$('pwdModal').classList.add('show');
$('oldPwd').value = ''; $('newPwd').value = ''; $('cfmPwd').value = ''; $('pwdMsg').textContent = '';
}
function closePwdModal() { $('pwdModal').classList.remove('show'); }
async function changePwd() {
var old = $('oldPwd').value, nw = $('newPwd').value, cf = $('cfmPwd').value;
if (!old || !nw) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '请填写所有字段'; return; }
if (nw.length < 8) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '新密码至少 8 位'; return; }
if (nw !== cf) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '两次密码不一致'; return; }
try {
var r = await fetch('/api/auth/change-password', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({old_password: old, new_password: nw})
});
var d = await r.json();
if (!r.ok) throw new Error(d.detail || '修改失败');
$('pwdMsg').className = 'modal-msg ok'; $('pwdMsg').textContent = d.message || '修改成功';
setTimeout(closePwdModal, 1200);
} catch(e) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = e.message; }
}
async function doLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/auth';
}
// ====== SUBSCRIPTION ======
async function post(url, body) {
var r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
@ -240,7 +187,6 @@ async function claimFreeTrial() {
}
}
loadUser();
loadMe();
</script>
{% endblock %}

View File

@ -74,6 +74,8 @@ _ID_TABLES = {
"screening_log",
"recommendation",
"price_tracking",
"paper_trades",
"paper_trade_events",
"cron_run_log",
"review_log",
"missed_explosions",

View File

@ -0,0 +1,32 @@
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
STATIC_DIR = ROOT / "static"
def test_base_template_owns_sidebar_and_user_shell():
forbidden_patterns = [
(r'id=["\']sidebar["\']', "sidebar element"),
(r'class=["\']sidebar["\']', "sidebar class"),
(r"\bsidebar-user\b", "sidebar user trigger"),
(r"\buserDropdown\b", "user dropdown"),
(r"\buserInitial\b", "user initial"),
(r"\buserEmailShort\b", "user email"),
(r"\bddEmail\b", "dropdown email"),
(r"\bfunction\s+toggleUserMenu\s*\(", "toggleUserMenu"),
(r"\basync\s+function\s+loadUser\s*\(", "loadUser"),
(r"\bfunction\s+showChangePwd\s*\(", "showChangePwd"),
(r"\bfunction\s+closePwdModal\s*\(", "closePwdModal"),
(r"\basync\s+function\s+changePwd\s*\(", "changePwd"),
(r"\basync\s+function\s+doLogout\s*\(", "doLogout"),
]
for path in STATIC_DIR.glob("*.html"):
if path.name in {"base.html", "index.html"}:
continue
text = path.read_text(encoding="utf-8")
if "extends \"base.html\"" not in text and "extends 'base.html'" not in text:
continue
for pattern, label in forbidden_patterns:
assert not re.search(pattern, text), f"{path.name} should inherit shell behavior from base.html, found {label}"

144
tests/test_paper_trading.py Normal file
View File

@ -0,0 +1,144 @@
import json
import pytest
from app.db import altcoin_db
from app.db.paper_trading import get_paper_trading_summary, list_paper_trades, sync_recommendation
@pytest.fixture
def buy_now_rec(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
altcoin_db.init_db()
rec_id = altcoin_db.create_recommendation(
symbol="PAPER/USDT",
rec_state="爆发",
rec_score=28,
entry_price=100,
stop_loss=95,
tp1=106,
tp2=112,
signals=["当前15min即刻入场信号"],
entry_plan={
"entry_action": "可即刻买入",
"entry_price": 100,
"stop_loss": 95,
"tp1": 106,
"tp2": 112,
"risk_reward_ok": True,
"rr1": 1.2,
"entry_trigger_confirmed": True,
},
)
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
return next(r for r in rows if r["id"] == rec_id)
def test_buy_now_opens_paper_trade_once(buy_now_rec):
first = sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
second = sync_recommendation(buy_now_rec, 101, event_time="2026-05-16T10:01:00")
assert first["opened"] is True
assert second["updated"] is True
trades = list_paper_trades()["items"]
assert len(trades) == 1
assert trades[0]["symbol"] == "PAPER/USDT"
assert trades[0]["status"] == "open"
assert trades[0]["pnl_pct"] == pytest.approx(1.0)
def test_default_paper_trade_uses_5000u_notional_5x_and_1000u_margin(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
monkeypatch.delenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", raising=False)
monkeypatch.delenv("ALPHAX_PAPER_TRADE_MARGIN_USDT", raising=False)
monkeypatch.delenv("ALPHAX_PAPER_TRADE_LEVERAGE", raising=False)
monkeypatch.delenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", raising=False)
monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
altcoin_db.init_db()
rec_id = altcoin_db.create_recommendation(
symbol="DEFAULT/USDT",
rec_state="爆发",
rec_score=28,
entry_price=100,
stop_loss=95,
tp1=106,
tp2=112,
signals=["当前15min即刻入场信号"],
entry_plan={"entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True},
)
rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id)
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
trade = list_paper_trades()["items"][0]
summary = get_paper_trading_summary(days=30)
assert trade["notional_usdt"] == pytest.approx(5000.0)
assert trade["leverage"] == pytest.approx(5.0)
assert trade["margin_usdt"] == pytest.approx(1000.0)
assert summary["account_equity_usdt"] == pytest.approx(20000.0)
assert summary["notional_usdt"] == pytest.approx(5000.0)
assert summary["margin_usdt"] == pytest.approx(1000.0)
def test_paper_margin_is_derived_from_notional_and_leverage(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "5000")
monkeypatch.setenv("ALPHAX_PAPER_TRADE_LEVERAGE", "5")
monkeypatch.setenv("ALPHAX_PAPER_TRADE_MARGIN_USDT", "5000")
summary = get_paper_trading_summary(days=30)
assert summary["notional_usdt"] == pytest.approx(5000.0)
assert summary["leverage"] == pytest.approx(5.0)
assert summary["margin_usdt"] == pytest.approx(1000.0)
def test_observation_does_not_open_paper_trade(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
altcoin_db.init_db()
rec_id = altcoin_db.create_recommendation(
symbol="OBS/USDT",
rec_state="加速",
rec_score=18,
entry_price=100,
stop_loss=95,
tp1=106,
signals=["历史放量"],
entry_plan={"entry_action": "观察", "entry_price": 100, "stop_loss": 95, "tp1": 106},
)
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
rec = next(r for r in rows if r["id"] == rec_id)
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
assert result["skipped"] is True
assert result["reason"] == "not_buy_now"
assert list_paper_trades()["total"] == 0
def test_open_paper_trade_closes_on_tp1_and_summary_counts_win(buy_now_rec):
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
result = sync_recommendation(buy_now_rec, 106, event_time="2026-05-16T10:05:00")
assert result["closed"] is True
assert result["exit_reason"] == "tp1"
assert result["pnl_pct"] == pytest.approx(6.0)
summary = get_paper_trading_summary(days=30)
assert summary["closed_count"] == 1
assert summary["win_count"] == 1
assert summary["win_rate"] == pytest.approx(100.0)
def test_disabled_paper_trading_skips_without_writing(monkeypatch, buy_now_rec):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "0")
result = sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
assert result["skipped"] is True
assert result["reason"] == "disabled"
assert list_paper_trades()["total"] == 0

View File

@ -0,0 +1,50 @@
import os
import sys
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR)
from app.services import price_tracker
def test_watch_only_recommendation_is_skipped_before_take_profit_push(monkeypatch):
rec = {
"id": 1,
"symbol": "QNT/USDT",
"entry_price": 100.0,
"stop_loss": 95.0,
"tp1": 110.0,
"tp2": 120.0,
"entry_plan": {"trailing_stop_level": 0.0},
"entry_triggered": 0,
"display_bucket": "watch_pool",
"execution_status": "observe",
"action_status": "观察",
}
monkeypatch.setattr(price_tracker, "get_active_recommendations", lambda actionable_only=True: [rec])
monkeypatch.setattr(price_tracker.exchange, "fetch_ticker", lambda symbol: {"last": 102.0})
monkeypatch.setattr(price_tracker, "update_latest_price_cache", lambda *args, **kwargs: None)
monkeypatch.setattr(price_tracker, "update_recommendation_tracking", lambda rec_id, current_price: {"status": "active"})
monkeypatch.setattr(price_tracker, "analyze_tracking_signals", lambda symbol, rec, current_price: {
"action_status": "持有",
"sell_signals": [],
"buy_signals": [],
"exhaustion": {"severity": "low"},
"pnl_pct": 0.0,
"trailing_stop_level": 0.0,
"trailing_stop_activated": False,
"trailing_stop_moved": False,
})
monkeypatch.setattr(price_tracker, "apply_recommendation_state_transition", lambda *args, **kwargs: {"action_status": "观察", "push_required": False})
pushed = []
monkeypatch.setattr(price_tracker, "push_trade_action_update", lambda *args, **kwargs: pushed.append((args, kwargs)) or True)
monkeypatch.setattr(price_tracker, "expire_old_recommendations", lambda: None)
monkeypatch.setattr(price_tracker, "get_stats", lambda: {"active_count": 1})
output = price_tracker.track_prices()
assert output["tracked_count"] == 0
assert output["results"][0]["status"] == "skipped_watch_only"
assert pushed == []

View File

@ -0,0 +1,112 @@
import os
import sys
import pytest
from fastapi.testclient import TestClient
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR)
from app.db import altcoin_db
from app.web import web_server
from test_actionable_active_recommendations import _insert_recommendation
@pytest.fixture
def temp_db(monkeypatch, tmp_path):
db_path = tmp_path / "altcoin_monitor.db"
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
monkeypatch.setattr(web_server, "get_all_recommendations", altcoin_db.get_all_recommendations)
monkeypatch.setattr(web_server, "get_stats", altcoin_db.get_stats)
altcoin_db.init_db()
return db_path
def test_archive_filter_separates_executed_and_invalid(temp_db):
_insert_recommendation(
temp_db,
symbol="EXEC/USDT",
action_status="止盈1",
status="hit_tp1",
execution_status="completed",
display_bucket="history",
entry_plan_json='{"entry_action": "可即刻买入"}',
)
_insert_recommendation(
temp_db,
symbol="EXPIRE/USDT",
action_status="衰减",
status="expired",
execution_status="invalid",
display_bucket="history",
entry_plan_json='{"entry_action": "继续观察"}',
)
conn = altcoin_db.sqlite3.connect(str(temp_db))
exec_id = conn.execute("SELECT id FROM recommendation WHERE symbol=%s", ("EXEC/USDT",)).fetchone()[0]
conn.execute(
"""
INSERT INTO paper_trades (
recommendation_id, symbol, status, opened_at, closed_at,
entry_price, exit_price, qty, notional_usdt,
stop_loss, tp1, tp2, current_price, pnl_pct,
realized_pnl_pct, realized_pnl_usdt, exit_reason,
created_at, updated_at
) VALUES (%s, %s, 'closed', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
exec_id,
"EXEC/USDT",
"2026-05-16T10:00:00",
"2026-05-16T10:10:00",
100.0,
106.0,
1.0,
100.0,
95.0,
106.0,
112.0,
106.0,
6.0,
6.0,
0.0,
"tp1",
"2026-05-16T10:00:00",
"2026-05-16T10:10:00",
),
)
conn.commit()
conn.close()
client = TestClient(web_server.app)
executed_page = client.get("/api/recommendations?decision_only=true&compact=true&archive_filter=executed").json()
symbols = {row["symbol"] for row in executed_page["items"]}
assert symbols == {"EXEC/USDT"}
executed_item = executed_page["items"][0]
assert executed_item["paper_trade"]["entry_price"] == pytest.approx(100.0)
assert executed_item["paper_trade_executed"] is True
invalid_page = client.get("/api/recommendations?decision_only=true&compact=true&archive_filter=invalid").json()
invalid_symbols = {row["symbol"] for row in invalid_page["items"]}
assert "EXPIRE/USDT" in invalid_symbols
for row in invalid_page["items"]:
assert not row.get("paper_trade")
def test_unexecuted_archive_items_do_not_have_paper_trade_payload(temp_db):
_insert_recommendation(
temp_db,
symbol="OBS/USDT",
action_status="等回踩",
status="expired",
execution_status="invalid",
display_bucket="history",
entry_plan_json='{"entry_action": "等回踩", "entry_price": 90}',
)
client = TestClient(web_server.app)
page = client.get("/api/recommendations?decision_only=true&compact=true&archive_filter=invalid").json()
item = next(row for row in page["items"] if row["symbol"] == "OBS/USDT")
assert item["paper_trade"] is None
assert item["paper_trade_executed"] is False

View File

@ -47,3 +47,29 @@ def test_ws_tracker_does_not_emit_entry_signal_for_closed_recommendation():
"action_status": "止盈1",
}
assert price_tracker_ws.check_triggers("NOT/USDT", rec, 0.000628) is None
def test_watch_pool_tracking_does_not_mark_stopped_out(monkeypatch, tmp_path):
db_path = tmp_path / "altcoin_monitor.db"
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
altcoin_db.init_db()
rec_id = altcoin_db.create_recommendation(
symbol="STORJ/USDT",
rec_state="爆发",
rec_score=12,
entry_price=0.1449,
stop_loss=0.12042,
tp1=0.177279,
tp2=0.206264,
entry_plan={"entry_action": "观察", "entry_price": 0.1338, "stop_loss": 0.12042, "tp1": 0.177279},
)
altcoin_db.update_recommendation_tracking(rec_id, 0.119)
rows = altcoin_db.get_all_recommendations(limit=10)
rec = next(r for r in rows if r["id"] == rec_id)
assert rec["status"] == "active"
assert rec["execution_status"] == "observe"
assert rec["entry_triggered"] == 0
assert rec["recommendation_result"] == "pending"