增加模拟交易
This commit is contained in:
parent
abb70bbe3e
commit
a292982845
@ -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":
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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:
|
||||
|
||||
50
app/db/migrations/0004_paper_trading.sql
Normal file
50
app/db/migrations/0004_paper_trading.sql
Normal 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);
|
||||
15
app/db/migrations/0005_paper_trading_leverage.sql
Normal file
15
app/db/migrations/0005_paper_trading_leverage.sql
Normal 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;
|
||||
14
app/db/migrations/0006_paper_trading_margin_model.sql
Normal file
14
app/db/migrations/0006_paper_trading_margin_model.sql
Normal 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
448
app/db/paper_trading.py
Normal 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),
|
||||
}
|
||||
@ -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",
|
||||
|
||||
83
app/services/paper_trader.py
Normal file
83
app/services/paper_trader.py
Normal 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
|
||||
@ -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']} "
|
||||
|
||||
@ -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)
|
||||
|
||||
24
app/web/routes_paper_trading.py
Normal file
24
app/web/routes_paper_trading.py
Normal 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)
|
||||
@ -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")
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
||||
|
||||
115
static/app.html
115
static/app.html
@ -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+'\">→</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\">→</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\">→</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>';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
74
static/paper_trading.html
Normal 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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
@ -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);
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -74,6 +74,8 @@ _ID_TABLES = {
|
||||
"screening_log",
|
||||
"recommendation",
|
||||
"price_tracking",
|
||||
"paper_trades",
|
||||
"paper_trade_events",
|
||||
"cron_run_log",
|
||||
"review_log",
|
||||
"missed_explosions",
|
||||
|
||||
32
tests/test_base_shell_ownership.py
Normal file
32
tests/test_base_shell_ownership.py
Normal 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
144
tests/test_paper_trading.py
Normal 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
|
||||
50
tests/test_price_tracker_watch_only_guard.py
Normal file
50
tests/test_price_tracker_watch_only_guard.py
Normal 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 == []
|
||||
112
tests/test_recommendation_archive_filter.py
Normal file
112
tests/test_recommendation_archive_filter.py
Normal 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
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user