添加移动止盈的动态计算
This commit is contained in:
parent
2e99af7486
commit
2d62f2f3d1
15
.env.example
15
.env.example
@ -74,6 +74,21 @@ ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER=0
|
||||
ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT=12
|
||||
ALPHAX_PAPER_ORDER_EXPIRE_HOURS=24
|
||||
|
||||
# 策略交易移动止盈。volatility 会按持仓后实际高低价波动动态调整启动阈值和保护距离。
|
||||
ALPHAX_PAPER_TRAILING_STOP_ENABLED=1
|
||||
ALPHAX_PAPER_TRAILING_MODE=volatility
|
||||
ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT=3
|
||||
ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT=0.5
|
||||
ALPHAX_PAPER_TRAILING_DISTANCE_PCT=1.5
|
||||
ALPHAX_PAPER_TRAILING_VOL_MIN_ACTIVATE_PCT=2.5
|
||||
ALPHAX_PAPER_TRAILING_VOL_MAX_ACTIVATE_PCT=8
|
||||
ALPHAX_PAPER_TRAILING_VOL_ACTIVATE_MULT=0.6
|
||||
ALPHAX_PAPER_TRAILING_VOL_MIN_DISTANCE_PCT=1.2
|
||||
ALPHAX_PAPER_TRAILING_VOL_MAX_DISTANCE_PCT=8
|
||||
ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT=0.7
|
||||
ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS=300
|
||||
ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT=2
|
||||
|
||||
ALPHAX_SYSTEM_ERROR_FEISHU_ENABLED=0
|
||||
ALPHAX_SYSTEM_ERROR_FEISHU_WEBHOOK=
|
||||
|
||||
|
||||
@ -104,9 +104,16 @@ def default_paper_trading_config():
|
||||
"fee_rate": _env_float("ALPHAX_PAPER_TRADE_FEE_RATE", 0.001),
|
||||
"slippage_pct": _env_float("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", 0.05),
|
||||
"trailing_stop_enabled": _env_bool("ALPHAX_PAPER_TRAILING_STOP_ENABLED", True),
|
||||
"trailing_mode": _env_str("ALPHAX_PAPER_TRAILING_MODE", "volatility"),
|
||||
"trailing_activate_pnl_pct": _env_float("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", 3.0),
|
||||
"trailing_min_lock_profit_pct": _env_float("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", 0.5),
|
||||
"trailing_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", 1.5),
|
||||
"trailing_volatility_min_activation_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MIN_ACTIVATE_PCT", 2.5),
|
||||
"trailing_volatility_max_activation_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MAX_ACTIVATE_PCT", 8.0),
|
||||
"trailing_volatility_activation_mult": _env_float("ALPHAX_PAPER_TRAILING_VOL_ACTIVATE_MULT", 0.6),
|
||||
"trailing_volatility_min_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MIN_DISTANCE_PCT", 1.2),
|
||||
"trailing_volatility_max_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MAX_DISTANCE_PCT", 8.0),
|
||||
"trailing_volatility_distance_mult": _env_float("ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT", 0.7),
|
||||
"trailing_move_push_min_interval_seconds": _env_int("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", 300),
|
||||
"trailing_move_push_min_step_pct": _env_float("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", 2.0),
|
||||
"order_gate_enabled": _env_bool("ALPHAX_PAPER_ORDER_GATE_ENABLED", True),
|
||||
|
||||
@ -455,7 +455,7 @@ def get_onchain_overview(hours=24):
|
||||
},
|
||||
"hot_tokens": ([_format_metric_item(row, active) for row in hot] or mapped_feed),
|
||||
"risk_tokens": [_format_metric_item(row, active) for row in risks],
|
||||
"raw_events": [_format_raw_event(row) for row in raw_latest],
|
||||
"raw_events": _format_raw_events(raw_latest),
|
||||
"signals": _signal_counts(standard_events),
|
||||
"provider_status": get_onchain_provider_status(hours=hours),
|
||||
}
|
||||
@ -792,7 +792,7 @@ def get_onchain_token_detail(symbol, hours=72):
|
||||
"hours": int(hours or 72),
|
||||
"mappings": [_with_raw(row) for row in mappings],
|
||||
"events": [_with_raw(row) for row in events],
|
||||
"raw_events": [_format_raw_event(row) for row in raw_events],
|
||||
"raw_events": _format_raw_events(raw_events),
|
||||
"raw_event_count": len(raw_events),
|
||||
"metrics": [_with_raw(row) for row in metrics],
|
||||
"recommendation": dict(rec) if rec else None,
|
||||
@ -871,7 +871,7 @@ def list_onchain_raw_events(limit=50, offset=0, chain="", source="", event_type=
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return {
|
||||
"items": [_format_raw_event(row) for row in rows],
|
||||
"items": _format_raw_events(rows),
|
||||
"total": int(total or 0),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
@ -900,13 +900,64 @@ def _with_raw(row):
|
||||
return item
|
||||
|
||||
|
||||
def _format_raw_event(row):
|
||||
def _format_raw_events(rows):
|
||||
rows = list(rows or [])
|
||||
metadata = _raw_event_token_metadata(rows)
|
||||
return [_format_raw_event(row, metadata.get(_raw_event_token_key(row), {})) for row in rows]
|
||||
|
||||
|
||||
def _raw_event_token_key(row):
|
||||
item = dict(row)
|
||||
return (str(item.get("chain") or "").lower(), str(item.get("token_address") or "").lower())
|
||||
|
||||
|
||||
def _raw_event_token_metadata(rows):
|
||||
keys = sorted({key for key in (_raw_event_token_key(row) for row in rows or []) if key[0] and key[1]})
|
||||
if not keys:
|
||||
return {}
|
||||
clauses = []
|
||||
params = []
|
||||
for chain, contract in keys:
|
||||
clauses.append("(chain=%s AND lower(contract_address)=lower(%s))")
|
||||
params.extend([chain, contract])
|
||||
conn = get_conn()
|
||||
try:
|
||||
found = conn.execute(
|
||||
f"""
|
||||
SELECT chain, contract_address, symbol, raw_json
|
||||
FROM onchain_token_map
|
||||
WHERE is_active=1 AND ({' OR '.join(clauses)})
|
||||
ORDER BY confidence DESC, updated_at DESC
|
||||
""",
|
||||
tuple(params),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
metadata = {}
|
||||
for row in found:
|
||||
key = (str(row["chain"] or "").lower(), str(row["contract_address"] or "").lower())
|
||||
if key in metadata:
|
||||
continue
|
||||
raw = _load(row["raw_json"], {}) or {}
|
||||
metadata[key] = {
|
||||
"symbol": normalize_symbol(row["symbol"]),
|
||||
"token_symbol": raw.get("symbol") or _symbol_base(row["symbol"]),
|
||||
"name": raw.get("name") or "",
|
||||
"decimals": int(raw.get("decimals") or 0),
|
||||
}
|
||||
return metadata
|
||||
|
||||
|
||||
def _format_raw_event(row, token_meta=None):
|
||||
item = _with_raw(row)
|
||||
token_meta = token_meta or {}
|
||||
item["event_label"] = raw_event_type_label(item.get("event_type"))
|
||||
explainer = raw_event_explainer(item.get("event_type"))
|
||||
item["plain_summary"] = explainer.get("plain") or ""
|
||||
item["why_matters"] = explainer.get("meaning") or ""
|
||||
item["priority"] = explainer.get("priority") or "medium"
|
||||
display = _humanize_raw_transfer(item, token_meta)
|
||||
item.update(display)
|
||||
item["pipeline_note"] = (
|
||||
"已映射,可进入后续链上信号分析。"
|
||||
if item.get("mapping_status") == "mapped"
|
||||
@ -916,6 +967,61 @@ def _format_raw_event(row):
|
||||
return item
|
||||
|
||||
|
||||
def _humanize_raw_transfer(item, token_meta):
|
||||
token_symbol = token_meta.get("token_symbol") or item.get("symbol_guess") or _symbol_base(item.get("mapped_symbol")) or "Token"
|
||||
decimals = int(token_meta.get("decimals") or 0)
|
||||
amount = float(item.get("total_amount") or item.get("amount") or 0)
|
||||
display_amount = amount
|
||||
if decimals > 0 and amount >= 10**decimals:
|
||||
display_amount = amount / (10**decimals)
|
||||
raw = item.get("raw") or {}
|
||||
topics = raw.get("topics") if isinstance(raw, dict) else []
|
||||
from_addr = _topic_address(topics[1]) if isinstance(topics, list) and len(topics) > 1 else ""
|
||||
to_addr = _topic_address(topics[2]) if isinstance(topics, list) and len(topics) > 2 else ""
|
||||
mapped = item.get("mapped_symbol") or normalize_symbol(token_symbol)
|
||||
amount_label = f"{_compact_number(display_amount)} {token_symbol}" if display_amount else f"未知数量 {token_symbol}"
|
||||
route = ""
|
||||
if from_addr and to_addr:
|
||||
route = f"从 {_short_address(from_addr)} 转至 {_short_address(to_addr)}"
|
||||
elif to_addr:
|
||||
route = f"转入 {_short_address(to_addr)}"
|
||||
summary = f"{mapped} 出现一笔 ERC-20 转账,数量约 {amount_label}"
|
||||
if route:
|
||||
summary += f",{route}"
|
||||
return {
|
||||
"display_amount": round(display_amount, 8) if display_amount else 0,
|
||||
"display_amount_label": amount_label,
|
||||
"from_address": from_addr,
|
||||
"to_address": to_addr,
|
||||
"from_short": _short_address(from_addr),
|
||||
"to_short": _short_address(to_addr),
|
||||
"human_summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def _topic_address(topic):
|
||||
topic = str(topic or "")
|
||||
if topic.startswith("0x") and len(topic) >= 42:
|
||||
return "0x" + topic[-40:]
|
||||
return ""
|
||||
|
||||
|
||||
def _compact_number(value):
|
||||
value = float(value or 0)
|
||||
abs_value = abs(value)
|
||||
if abs_value >= 1_000_000_000:
|
||||
return f"{value / 1_000_000_000:.2f}B"
|
||||
if abs_value >= 1_000_000:
|
||||
return f"{value / 1_000_000:.2f}M"
|
||||
if abs_value >= 1_000:
|
||||
return f"{value / 1_000:.2f}K"
|
||||
if abs_value >= 1:
|
||||
return f"{value:.2f}".rstrip("0").rstrip(".")
|
||||
if abs_value > 0:
|
||||
return f"{value:.6f}".rstrip("0").rstrip(".")
|
||||
return "0"
|
||||
|
||||
|
||||
def _short_address(value):
|
||||
value = str(value or "")
|
||||
if len(value) <= 14:
|
||||
|
||||
@ -106,9 +106,16 @@ def _trailing_config() -> dict:
|
||||
cfg = paper_trading_config()
|
||||
return {
|
||||
"enabled": bool(cfg.get("trailing_stop_enabled", True)),
|
||||
"mode": str(cfg.get("trailing_mode") or "volatility").strip().lower(),
|
||||
"activate_pnl_pct": max(0.0, _safe_float(cfg.get("trailing_activate_pnl_pct"), 3.0)),
|
||||
"min_lock_profit_pct": max(0.0, _safe_float(cfg.get("trailing_min_lock_profit_pct"), 0.5)),
|
||||
"distance_pct": max(0.1, _safe_float(cfg.get("trailing_distance_pct"), 1.5)),
|
||||
"vol_min_activation_pct": max(0.0, _safe_float(cfg.get("trailing_volatility_min_activation_pct"), 2.5)),
|
||||
"vol_max_activation_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_max_activation_pct"), 8.0)),
|
||||
"vol_activation_mult": max(0.0, _safe_float(cfg.get("trailing_volatility_activation_mult"), 0.6)),
|
||||
"vol_min_distance_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_min_distance_pct"), 1.2)),
|
||||
"vol_max_distance_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_max_distance_pct"), 8.0)),
|
||||
"vol_distance_mult": max(0.0, _safe_float(cfg.get("trailing_volatility_distance_mult"), 0.7)),
|
||||
"move_push_min_interval_seconds": max(0, _safe_int(cfg.get("trailing_move_push_min_interval_seconds"), 300)),
|
||||
"move_push_min_step_pct": max(0.0, _safe_float(cfg.get("trailing_move_push_min_step_pct"), 2.0)),
|
||||
"tiers": cfg.get("trailing_tiers") if isinstance(cfg.get("trailing_tiers"), list) else [],
|
||||
@ -127,6 +134,58 @@ def _trailing_distance_pct(pnl_pct: float, cfg: dict) -> tuple[float, str]:
|
||||
return distance, label
|
||||
|
||||
|
||||
def _clamp(value: float, min_value: float, max_value: float) -> float:
|
||||
low = min(min_value, max_value)
|
||||
high = max(min_value, max_value)
|
||||
return max(low, min(high, value))
|
||||
|
||||
|
||||
def _trade_observed_volatility_pct(trade: dict, current_price: float) -> float:
|
||||
entry = _safe_float(trade.get("entry_price"))
|
||||
if entry <= 0 or current_price <= 0:
|
||||
return 0.0
|
||||
high = max(_safe_float(trade.get("max_price")) or entry, current_price, entry)
|
||||
low = min(_safe_float(trade.get("min_price")) or entry, current_price, entry)
|
||||
return round(max(0.0, (high - low) / entry * 100), 6)
|
||||
|
||||
|
||||
def _dynamic_trailing_profile(trade: dict, current_price: float, pnl_pct: float, cfg: dict) -> dict:
|
||||
base_activate = _safe_float(cfg.get("activate_pnl_pct"), 3.0)
|
||||
base_distance, tier_label = _trailing_distance_pct(pnl_pct, cfg)
|
||||
volatility_pct = _trade_observed_volatility_pct(trade, current_price)
|
||||
if str(cfg.get("mode") or "volatility").lower() != "volatility":
|
||||
return {
|
||||
"mode": "fixed",
|
||||
"volatility_pct": volatility_pct,
|
||||
"activate_pnl_pct": base_activate,
|
||||
"distance_pct": base_distance,
|
||||
"tier_label": tier_label,
|
||||
}
|
||||
|
||||
dynamic_activate = max(base_activate, volatility_pct * _safe_float(cfg.get("vol_activation_mult"), 0.6))
|
||||
dynamic_activate = _clamp(
|
||||
dynamic_activate,
|
||||
_safe_float(cfg.get("vol_min_activation_pct"), 2.5),
|
||||
_safe_float(cfg.get("vol_max_activation_pct"), 8.0),
|
||||
)
|
||||
dynamic_distance = max(base_distance, volatility_pct * _safe_float(cfg.get("vol_distance_mult"), 0.7))
|
||||
dynamic_distance = _clamp(
|
||||
dynamic_distance,
|
||||
_safe_float(cfg.get("vol_min_distance_pct"), 1.2),
|
||||
_safe_float(cfg.get("vol_max_distance_pct"), 8.0),
|
||||
)
|
||||
label = tier_label or "波动率"
|
||||
return {
|
||||
"mode": "volatility",
|
||||
"volatility_pct": volatility_pct,
|
||||
"activate_pnl_pct": round(dynamic_activate, 6),
|
||||
"distance_pct": round(dynamic_distance, 6),
|
||||
"tier_label": label,
|
||||
"base_activate_pnl_pct": base_activate,
|
||||
"base_distance_pct": base_distance,
|
||||
}
|
||||
|
||||
|
||||
def _parse_time(value: str) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
@ -955,14 +1014,26 @@ def _close_trade(conn, trade: dict, current_price: float, reason: str, event_tim
|
||||
def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: float, event_time: str) -> tuple[float, dict]:
|
||||
cfg = _trailing_config()
|
||||
current_trail = _safe_float(trade.get("trailing_stop"))
|
||||
if not cfg.get("enabled") or pnl_pct < _safe_float(cfg.get("activate_pnl_pct")):
|
||||
if not cfg.get("enabled"):
|
||||
return current_trail, {"activated": False, "moved": False}
|
||||
|
||||
entry_price = _safe_float(trade.get("entry_price"))
|
||||
if entry_price <= 0 or current_price <= 0:
|
||||
return current_trail, {"activated": False, "moved": False}
|
||||
|
||||
distance_pct, tier_label = _trailing_distance_pct(pnl_pct, cfg)
|
||||
profile = _dynamic_trailing_profile(trade, current_price, pnl_pct, cfg)
|
||||
activate_pnl_pct = _safe_float(profile.get("activate_pnl_pct"), cfg.get("activate_pnl_pct"))
|
||||
if pnl_pct < activate_pnl_pct:
|
||||
return current_trail, {
|
||||
"activated": False,
|
||||
"moved": False,
|
||||
"trailing_mode": profile.get("mode"),
|
||||
"volatility_pct": profile.get("volatility_pct"),
|
||||
"activate_pnl_pct": activate_pnl_pct,
|
||||
}
|
||||
|
||||
distance_pct = _safe_float(profile.get("distance_pct"), cfg.get("distance_pct"))
|
||||
tier_label = str(profile.get("tier_label") or "")
|
||||
protection_floor = entry_price * (1 + _safe_float(cfg.get("min_lock_profit_pct")) / 100)
|
||||
candidate = current_price * (1 - distance_pct / 100)
|
||||
new_trail = round(max(current_trail, protection_floor, candidate), 12)
|
||||
@ -989,9 +1060,13 @@ def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: floa
|
||||
"current_price": current_price,
|
||||
"previous_trailing_stop": current_trail,
|
||||
"trailing_stop": new_trail,
|
||||
"activate_pnl_pct": cfg.get("activate_pnl_pct"),
|
||||
"activate_pnl_pct": activate_pnl_pct,
|
||||
"distance_pct": distance_pct,
|
||||
"tier_label": tier_label,
|
||||
"trailing_mode": profile.get("mode"),
|
||||
"volatility_pct": profile.get("volatility_pct"),
|
||||
"base_activate_pnl_pct": profile.get("base_activate_pnl_pct"),
|
||||
"base_distance_pct": profile.get("base_distance_pct"),
|
||||
"min_lock_profit_pct": cfg.get("min_lock_profit_pct"),
|
||||
"notification_throttled": False,
|
||||
},
|
||||
@ -1004,7 +1079,10 @@ def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: floa
|
||||
"trailing_stop": new_trail,
|
||||
"previous_trailing_stop": current_trail,
|
||||
"distance_pct": distance_pct,
|
||||
"activate_pnl_pct": activate_pnl_pct,
|
||||
"tier_label": tier_label,
|
||||
"trailing_mode": profile.get("mode"),
|
||||
"volatility_pct": profile.get("volatility_pct"),
|
||||
"notification_emitted": should_emit,
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -234,6 +234,14 @@ def test_overview_ignores_legacy_signals_and_surfaces_mapped_raw_feed(monkeypatc
|
||||
|
||||
def test_token_detail_includes_mapped_raw_events(monkeypatch, tmp_path):
|
||||
_temp_db(monkeypatch, tmp_path)
|
||||
onchain_db.upsert_token_mapping(
|
||||
"BEAM/USDT",
|
||||
"bsc",
|
||||
"0xbeam",
|
||||
source="nodereal_erc20_metadata",
|
||||
confidence=90,
|
||||
raw={"symbol": "BEAM", "name": "Beam", "decimals": 18},
|
||||
)
|
||||
onchain_db.insert_onchain_raw_event(
|
||||
{
|
||||
"source": "nodereal",
|
||||
@ -241,8 +249,8 @@ def test_token_detail_includes_mapped_raw_events(monkeypatch, tmp_path):
|
||||
"event_type": "evm_transfer",
|
||||
"token_address": "0xbeam",
|
||||
"title": "NodeReal ERC-20 原始转账",
|
||||
"amount": 300,
|
||||
"total_amount": 300,
|
||||
"amount": 300 * 10**18,
|
||||
"total_amount": 300 * 10**18,
|
||||
"importance": 78,
|
||||
"mapped_symbol": "BEAM/USDT",
|
||||
"mapping_status": "mapped",
|
||||
@ -255,6 +263,8 @@ def test_token_detail_includes_mapped_raw_events(monkeypatch, tmp_path):
|
||||
assert detail["events"] == []
|
||||
assert detail["raw_event_count"] == 1
|
||||
assert detail["raw_events"][0]["mapped_symbol"] == "BEAM/USDT"
|
||||
assert detail["raw_events"][0]["display_amount_label"] == "300 BEAM"
|
||||
assert "数量约 300 BEAM" in detail["raw_events"][0]["human_summary"]
|
||||
assert detail["raw_events"][0]["pipeline_note"] == "已映射,可进入后续链上信号分析。"
|
||||
|
||||
|
||||
|
||||
@ -535,6 +535,7 @@ def test_trailing_move_push_is_throttled_but_stop_still_updates(monkeypatch, buy
|
||||
"risk_reward_ok": True,
|
||||
}
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", "300")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", "2")
|
||||
@ -624,6 +625,7 @@ def test_open_paper_trade_closes_on_tp1_and_summary_counts_win(buy_now_rec):
|
||||
def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy_now_rec):
|
||||
pushed = []
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||
@ -650,6 +652,58 @@ def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy
|
||||
_assert_no_paper_trading_copy(pushed[-1])
|
||||
|
||||
|
||||
def test_volatility_trailing_uses_wider_distance_for_choppy_coin(monkeypatch, buy_now_rec):
|
||||
rec = dict(buy_now_rec)
|
||||
rec["tp1"] = 200
|
||||
rec["tp2"] = 220
|
||||
rec["entry_plan"] = {
|
||||
"entry_action": "可即刻买入",
|
||||
"entry_price": 100,
|
||||
"stop_loss": 95,
|
||||
"tp1": 200,
|
||||
"tp2": 220,
|
||||
"entry_trigger_confirmed": True,
|
||||
"risk_reward_ok": True,
|
||||
}
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "volatility")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_ACTIVATE_MULT", "0.6")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT", "0.7")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_MAX_DISTANCE_PCT", "8")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_MAX_ACTIVATE_PCT", "8")
|
||||
|
||||
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
sync_recommendation(rec, 98, event_time="2026-05-16T10:01:00")
|
||||
result = sync_recommendation(rec, 110, event_time="2026-05-16T10:02:00")
|
||||
|
||||
assert result["activated"] is True
|
||||
assert result["trailing_mode"] == "volatility"
|
||||
assert result["volatility_pct"] == pytest.approx(12.0)
|
||||
assert result["activate_pnl_pct"] == pytest.approx(7.2)
|
||||
assert result["distance_pct"] == pytest.approx(8.0)
|
||||
assert result["trailing_stop"] == pytest.approx(101.2)
|
||||
|
||||
|
||||
def test_volatility_trailing_stays_tighter_for_smooth_coin(monkeypatch, buy_now_rec):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "volatility")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT", "0.7")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_MIN_DISTANCE_PCT", "1.2")
|
||||
|
||||
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||
result = sync_recommendation(buy_now_rec, 104, event_time="2026-05-16T10:01:00")
|
||||
|
||||
assert result["activated"] is True
|
||||
assert result["trailing_mode"] == "volatility"
|
||||
assert result["volatility_pct"] == pytest.approx(4.0)
|
||||
assert result["distance_pct"] == pytest.approx(2.8)
|
||||
assert result["trailing_stop"] == pytest.approx(101.088)
|
||||
|
||||
|
||||
def test_paper_push_failure_is_recorded(monkeypatch, buy_now_rec):
|
||||
errors = []
|
||||
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: (False, "webhook failed"))
|
||||
@ -664,6 +718,7 @@ def test_paper_push_failure_is_recorded(monkeypatch, buy_now_rec):
|
||||
|
||||
def test_paper_trading_trailing_stop_never_moves_down(monkeypatch, buy_now_rec):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||
@ -680,6 +735,7 @@ def test_paper_trading_trailing_stop_never_moves_down(monkeypatch, buy_now_rec):
|
||||
|
||||
def test_paper_trading_events_capture_open_close_and_trailing(monkeypatch, buy_now_rec):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||
|
||||
@ -69,6 +69,7 @@ def test_price_streamer_tick_opens_and_closes_paper_trade(buy_now_rec):
|
||||
|
||||
def test_price_streamer_tick_drives_paper_trailing_stop(monkeypatch, buy_now_rec):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user