Initial TradeBot implementation
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from crypto_spot_bot.config import Settings
|
||||
from crypto_spot_bot.storage import Storage
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LearningAdjustment:
|
||||
symbol: str
|
||||
pattern: str
|
||||
sample_size: int
|
||||
net_pnl: float
|
||||
win_rate: float
|
||||
confidence_adjustment: float
|
||||
reason: str
|
||||
adaptive_rules: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LearningState:
|
||||
enabled: bool
|
||||
sample_size: int
|
||||
net_pnl: float
|
||||
win_rate: float
|
||||
symbol_stats: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
pattern_stats: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
adaptive_rules: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class TradeLearner:
|
||||
def __init__(self, settings: Settings, storage: Storage):
|
||||
self.settings = settings
|
||||
self.storage = storage
|
||||
self.state = LearningState(
|
||||
enabled=settings.learning_enabled,
|
||||
sample_size=0,
|
||||
net_pnl=0.0,
|
||||
win_rate=0.0,
|
||||
adaptive_rules=_neutral_rules(settings, "мало закрытых сделок для изменения правил"),
|
||||
)
|
||||
|
||||
def refresh(self) -> LearningState:
|
||||
if not self.settings.learning_enabled:
|
||||
self.state = LearningState(
|
||||
enabled=False,
|
||||
sample_size=0,
|
||||
net_pnl=0.0,
|
||||
win_rate=0.0,
|
||||
adaptive_rules={"enabled": False, "reason": "обучение выключено"},
|
||||
)
|
||||
self.storage.set_runtime("learning_state", self.state.as_dict())
|
||||
return self.state
|
||||
|
||||
trades = self.storage.closed_trades(self.settings.learning_lookback_trades)
|
||||
total_net = sum(float(trade.get("net_pnl") or 0.0) for trade in trades)
|
||||
wins = sum(1 for trade in trades if float(trade.get("net_pnl") or 0.0) > 0)
|
||||
symbol_stats = _group_stats(trades, "symbol")
|
||||
pattern_stats = _group_stats(trades, "entry_pattern")
|
||||
reason_stats = _group_stats(trades, "reason")
|
||||
adaptive_rules = _build_adaptive_rules(
|
||||
trades=trades,
|
||||
settings=self.settings,
|
||||
total_net=total_net,
|
||||
wins=wins,
|
||||
symbol_stats=symbol_stats,
|
||||
pattern_stats=pattern_stats,
|
||||
reason_stats=reason_stats,
|
||||
)
|
||||
self.state = LearningState(
|
||||
enabled=True,
|
||||
sample_size=len(trades),
|
||||
net_pnl=round(total_net, 6),
|
||||
win_rate=round(wins / len(trades), 4) if trades else 0.0,
|
||||
symbol_stats=symbol_stats,
|
||||
pattern_stats=pattern_stats,
|
||||
adaptive_rules=adaptive_rules,
|
||||
)
|
||||
self.storage.set_runtime("learning_state", self.state.as_dict())
|
||||
return self.state
|
||||
|
||||
def adjustment_for(self, symbol: str, pattern: str) -> LearningAdjustment:
|
||||
if not self.settings.learning_enabled:
|
||||
return LearningAdjustment(symbol, pattern, 0, 0.0, 0.0, 0.0, "обучение выключено")
|
||||
state = self.state
|
||||
symbol_stat = state.symbol_stats.get(symbol, {})
|
||||
pattern_stat = state.pattern_stats.get(pattern, {})
|
||||
symbol_adj = self._stat_adjustment(symbol_stat)
|
||||
pattern_adj = self._stat_adjustment(pattern_stat)
|
||||
adjustment = _clamp(
|
||||
symbol_adj + pattern_adj,
|
||||
-self.settings.learning_max_adjustment,
|
||||
self.settings.learning_max_adjustment,
|
||||
)
|
||||
samples = int(symbol_stat.get("sample_size", 0)) + int(pattern_stat.get("sample_size", 0))
|
||||
net_pnl = float(symbol_stat.get("net_pnl", 0.0)) + float(pattern_stat.get("net_pnl", 0.0))
|
||||
win_rate = _weighted_win_rate(symbol_stat, pattern_stat)
|
||||
if samples < self.settings.learning_min_samples:
|
||||
adjustment = 0.0
|
||||
reason = "мало закрытых сделок для вывода"
|
||||
elif adjustment > 0:
|
||||
reason = "прошлые сделки по символу/шаблону были лучше среднего"
|
||||
elif adjustment < 0:
|
||||
reason = "прошлые сделки по символу/шаблону были убыточными"
|
||||
else:
|
||||
reason = "статистика нейтральна"
|
||||
return LearningAdjustment(
|
||||
symbol=symbol,
|
||||
pattern=pattern,
|
||||
sample_size=samples,
|
||||
net_pnl=round(net_pnl, 6),
|
||||
win_rate=round(win_rate, 4),
|
||||
confidence_adjustment=round(adjustment, 4),
|
||||
reason=reason,
|
||||
adaptive_rules=self.rules_for(symbol, pattern),
|
||||
)
|
||||
|
||||
def rules_for(self, symbol: str, pattern: str = "") -> dict[str, Any]:
|
||||
rules = dict(self.state.adaptive_rules or {})
|
||||
symbol_adjustments = rules.get("symbol_threshold_adjustments") or {}
|
||||
pattern_adjustments = rules.get("pattern_threshold_adjustments") or {}
|
||||
symbol_adjustment = float(symbol_adjustments.get(symbol, 0.0) or 0.0)
|
||||
pattern_adjustment = float(pattern_adjustments.get(pattern, 0.0) or 0.0)
|
||||
base_adjustment = float(rules.get("entry_threshold_adjustment", 0.0) or 0.0)
|
||||
effective = _clamp(
|
||||
base_adjustment + symbol_adjustment + pattern_adjustment,
|
||||
-self.settings.learning_max_adjustment,
|
||||
self.settings.learning_max_adjustment,
|
||||
)
|
||||
rules["symbol_entry_threshold_adjustment"] = round(symbol_adjustment, 4)
|
||||
rules["pattern_entry_threshold_adjustment"] = round(pattern_adjustment, 4)
|
||||
rules["effective_entry_threshold_adjustment"] = round(effective, 4)
|
||||
rules["symbol_blocked"] = symbol in set(rules.get("blocked_symbols") or [])
|
||||
rules["pattern_blocked"] = pattern in set(rules.get("blocked_patterns") or [])
|
||||
return rules
|
||||
|
||||
def _stat_adjustment(self, stat: dict[str, Any]) -> float:
|
||||
sample_size = int(stat.get("sample_size", 0))
|
||||
if sample_size < self.settings.learning_min_samples:
|
||||
return 0.0
|
||||
net_pnl = float(stat.get("net_pnl", 0.0))
|
||||
win_rate = float(stat.get("win_rate", 0.0))
|
||||
avg_pnl = net_pnl / sample_size
|
||||
raw = (win_rate - 0.5) * 0.12 + avg_pnl * 0.05
|
||||
return _clamp(
|
||||
raw,
|
||||
-self.settings.learning_max_adjustment / 2,
|
||||
self.settings.learning_max_adjustment / 2,
|
||||
)
|
||||
|
||||
|
||||
def _group_stats(trades: list[dict[str, Any]], key: str) -> dict[str, dict[str, Any]]:
|
||||
buckets: dict[str, list[dict[str, Any]]] = {}
|
||||
for trade in trades:
|
||||
raw = str(trade.get(key) or "неизвестно").strip() or "неизвестно"
|
||||
buckets.setdefault(raw, []).append(trade)
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for name, rows in buckets.items():
|
||||
net = sum(float(row.get("net_pnl") or 0.0) for row in rows)
|
||||
wins = sum(1 for row in rows if float(row.get("net_pnl") or 0.0) > 0)
|
||||
losses = sum(1 for row in rows if float(row.get("net_pnl") or 0.0) < 0)
|
||||
result[name] = {
|
||||
"sample_size": len(rows),
|
||||
"net_pnl": round(net, 6),
|
||||
"win_count": wins,
|
||||
"loss_count": losses,
|
||||
"win_rate": round(wins / len(rows), 4) if rows else 0.0,
|
||||
"average_net_pnl": round(net / len(rows), 6) if rows else 0.0,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _weighted_win_rate(*stats: dict[str, Any]) -> float:
|
||||
wins = 0.0
|
||||
samples = 0.0
|
||||
for stat in stats:
|
||||
sample_size = float(stat.get("sample_size", 0) or 0)
|
||||
wins += float(stat.get("win_rate", 0.0) or 0.0) * sample_size
|
||||
samples += sample_size
|
||||
return wins / samples if samples else 0.0
|
||||
|
||||
|
||||
def _build_adaptive_rules(
|
||||
*,
|
||||
trades: list[dict[str, Any]],
|
||||
settings: Settings,
|
||||
total_net: float,
|
||||
wins: int,
|
||||
symbol_stats: dict[str, dict[str, Any]],
|
||||
pattern_stats: dict[str, dict[str, Any]],
|
||||
reason_stats: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
sample_size = len(trades)
|
||||
rules = _neutral_rules(settings)
|
||||
rules["sample_size"] = sample_size
|
||||
rules["net_pnl"] = round(total_net, 6)
|
||||
rules["win_rate"] = round(wins / sample_size, 4) if sample_size else 0.0
|
||||
rules["exit_reason_stats"] = reason_stats
|
||||
if sample_size < settings.learning_min_samples:
|
||||
rules["reasons"].append("мало закрытых сделок для изменения правил")
|
||||
return rules
|
||||
|
||||
win_rate = wins / sample_size if sample_size else 0.0
|
||||
if total_net < 0:
|
||||
threshold = _clamp(
|
||||
(0.5 - win_rate) * 0.12 + min(abs(total_net) / max(sample_size, 1), 0.05),
|
||||
0.0,
|
||||
settings.learning_max_adjustment,
|
||||
)
|
||||
rules["entry_threshold_adjustment"] = round(threshold, 4)
|
||||
rules["risk_mode"] = "defensive"
|
||||
rules["trade_permission"] = "capital_protection"
|
||||
rules["reduce_exposure"] = True
|
||||
rules["bad_market_entry_block"] = True
|
||||
rules["target_total_exposure_usdt"] = round(
|
||||
min(settings.max_total_exposure_usdt, max(settings.min_position_usdt, settings.starting_balance_usdt * 0.35)),
|
||||
2,
|
||||
)
|
||||
rules["target_symbol_exposure_usdt"] = round(
|
||||
min(settings.max_symbol_exposure_usdt, max(settings.min_position_usdt, settings.max_position_usdt * 0.5)),
|
||||
2,
|
||||
)
|
||||
rules["min_hold_seconds"] = int(min(max(settings.min_hold_seconds * 2, 300), 900))
|
||||
if win_rate <= 0.25:
|
||||
rules["stop_loss_percent"] = round(max(0.008, settings.stop_loss_percent * 0.85), 4)
|
||||
rules["take_profit_percent"] = round(
|
||||
max(settings.take_profit_percent, (_round_trip_cost_percent(settings) + 0.6) / 100),
|
||||
4,
|
||||
)
|
||||
rules["reasons"].append("общая статистика обучения убыточна: вход ужесточен, риск снижен")
|
||||
elif win_rate >= 0.55:
|
||||
threshold = -min(settings.learning_max_adjustment / 2, (win_rate - 0.5) * 0.08)
|
||||
rules["entry_threshold_adjustment"] = round(threshold, 4)
|
||||
rules["risk_mode"] = "expansion"
|
||||
rules["reasons"].append("общая статистика обучения положительная: вход можно немного расширить")
|
||||
|
||||
for name, stat in symbol_stats.items():
|
||||
adjustment = _threshold_adjustment_from_stat(stat, settings)
|
||||
if adjustment:
|
||||
rules["symbol_threshold_adjustments"][name] = adjustment
|
||||
if _should_block_stat(stat, settings):
|
||||
rules["blocked_symbols"].append(name)
|
||||
|
||||
for name, stat in pattern_stats.items():
|
||||
adjustment = _threshold_adjustment_from_stat(stat, settings)
|
||||
if adjustment:
|
||||
rules["pattern_threshold_adjustments"][name] = adjustment
|
||||
if _should_block_stat(stat, settings):
|
||||
rules["blocked_patterns"].append(name)
|
||||
|
||||
for reason, stat in reason_stats.items():
|
||||
if not _is_bad_stat(stat, settings):
|
||||
continue
|
||||
reason_text = reason.lower()
|
||||
if "ema50" in reason_text:
|
||||
rules["ema_exit_mode"] = "profit_only"
|
||||
rules["reasons"].append("выход по EMA50 убыточен: разрешен только при прибыли после издержек")
|
||||
if "rsi" in reason_text:
|
||||
rules["rsi_exit_mode"] = "profit_only"
|
||||
rules["reasons"].append("выход по RSI убыточен: разрешен только при прибыли после издержек")
|
||||
|
||||
rules["blocked_symbols"] = sorted(set(rules["blocked_symbols"]))
|
||||
rules["blocked_patterns"] = sorted(set(rules["blocked_patterns"]))
|
||||
validation = _closed_trade_validation(trades, rules)
|
||||
rules["validation"] = validation
|
||||
if validation["status"] == "rejected":
|
||||
fallback = _neutral_rules(settings, "адаптивные правила не прошли проверку на закрытых сделках")
|
||||
fallback["validation"] = validation
|
||||
return fallback
|
||||
return rules
|
||||
|
||||
|
||||
def _neutral_rules(settings: Settings, reason: str | None = None) -> dict[str, Any]:
|
||||
rules: dict[str, Any] = {
|
||||
"enabled": settings.learning_enabled,
|
||||
"sample_size": 0,
|
||||
"net_pnl": 0.0,
|
||||
"win_rate": 0.0,
|
||||
"round_trip_cost_percent": round(_round_trip_cost_percent(settings), 4),
|
||||
"entry_threshold_adjustment": 0.0,
|
||||
"risk_mode": "neutral",
|
||||
"trade_permission": "normal",
|
||||
"allow_new_entries": True,
|
||||
"reduce_exposure": False,
|
||||
"bad_market_entry_block": False,
|
||||
"target_total_exposure_usdt": settings.max_total_exposure_usdt,
|
||||
"target_symbol_exposure_usdt": settings.max_symbol_exposure_usdt,
|
||||
"current_total_exposure_usdt": 0.0,
|
||||
"over_target_exposure": False,
|
||||
"reduce_now": False,
|
||||
"min_hold_seconds": settings.min_hold_seconds,
|
||||
"min_exit_profit_percent": round(_round_trip_cost_percent(settings) + 0.05, 4),
|
||||
"ema_exit_mode": "normal",
|
||||
"rsi_exit_mode": "normal",
|
||||
"stop_loss_percent": settings.stop_loss_percent,
|
||||
"take_profit_percent": settings.take_profit_percent,
|
||||
"trailing_stop_percent": settings.trailing_stop_percent,
|
||||
"symbol_threshold_adjustments": {},
|
||||
"pattern_threshold_adjustments": {},
|
||||
"blocked_symbols": [],
|
||||
"blocked_patterns": [],
|
||||
"exit_reason_stats": {},
|
||||
"validation": {
|
||||
"method": "closed_trade_counterfactual",
|
||||
"status": "not_enough_data",
|
||||
"sample_size": 0,
|
||||
"baseline_net_pnl": 0.0,
|
||||
"validated_net_pnl": 0.0,
|
||||
"skipped_trades": 0,
|
||||
"skipped_net_pnl": 0.0,
|
||||
"avoided_loss_usdt": 0.0,
|
||||
},
|
||||
"reasons": [],
|
||||
}
|
||||
if reason:
|
||||
rules["reasons"].append(reason)
|
||||
return rules
|
||||
|
||||
|
||||
def _round_trip_cost_percent(settings: Settings) -> float:
|
||||
return (settings.taker_fee_rate * 2 + settings.slippage_rate * 2) * 100
|
||||
|
||||
|
||||
def _closed_trade_validation(trades: list[dict[str, Any]], rules: dict[str, Any]) -> dict[str, Any]:
|
||||
baseline_net = sum(float(trade.get("net_pnl") or 0.0) for trade in trades)
|
||||
skipped_net = 0.0
|
||||
kept_net = 0.0
|
||||
skipped_count = 0
|
||||
for trade in trades:
|
||||
net_pnl = float(trade.get("net_pnl") or 0.0)
|
||||
if _would_skip_closed_trade(trade, rules):
|
||||
skipped_net += net_pnl
|
||||
skipped_count += 1
|
||||
else:
|
||||
kept_net += net_pnl
|
||||
avoided_loss = kept_net - baseline_net
|
||||
status = "accepted" if avoided_loss >= 0 else "rejected"
|
||||
return {
|
||||
"method": "closed_trade_counterfactual",
|
||||
"status": status,
|
||||
"sample_size": len(trades),
|
||||
"baseline_net_pnl": round(baseline_net, 6),
|
||||
"validated_net_pnl": round(kept_net, 6),
|
||||
"skipped_trades": skipped_count,
|
||||
"skipped_net_pnl": round(skipped_net, 6),
|
||||
"avoided_loss_usdt": round(avoided_loss, 6),
|
||||
}
|
||||
|
||||
|
||||
def _would_skip_closed_trade(trade: dict[str, Any], rules: dict[str, Any]) -> bool:
|
||||
reason = str(trade.get("reason") or "").lower()
|
||||
pattern = str(trade.get("entry_pattern") or "")
|
||||
symbol = str(trade.get("symbol") or "")
|
||||
net_pnl = float(trade.get("net_pnl") or 0.0)
|
||||
if net_pnl >= 0:
|
||||
return False
|
||||
if "ema50" in reason and rules.get("ema_exit_mode") == "profit_only":
|
||||
return True
|
||||
if "rsi" in reason and rules.get("rsi_exit_mode") == "profit_only":
|
||||
return True
|
||||
if symbol in set(rules.get("blocked_symbols") or []):
|
||||
return True
|
||||
if pattern in set(rules.get("blocked_patterns") or []):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _threshold_adjustment_from_stat(stat: dict[str, Any], settings: Settings) -> float:
|
||||
sample_size = int(stat.get("sample_size", 0) or 0)
|
||||
if sample_size < settings.learning_min_samples:
|
||||
return 0.0
|
||||
net_pnl = float(stat.get("net_pnl", 0.0) or 0.0)
|
||||
win_rate = float(stat.get("win_rate", 0.0) or 0.0)
|
||||
avg_pnl = net_pnl / sample_size if sample_size else 0.0
|
||||
if net_pnl < 0:
|
||||
raw = (0.5 - win_rate) * 0.08 + min(abs(avg_pnl), 0.08) * 0.4
|
||||
return round(_clamp(raw, 0.0, settings.learning_max_adjustment / 2), 4)
|
||||
if win_rate >= 0.55 and avg_pnl > 0:
|
||||
raw = -((win_rate - 0.5) * 0.05 + min(avg_pnl, 0.08) * 0.2)
|
||||
return round(_clamp(raw, -settings.learning_max_adjustment / 2, 0.0), 4)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _is_bad_stat(stat: dict[str, Any], settings: Settings) -> bool:
|
||||
sample_size = int(stat.get("sample_size", 0) or 0)
|
||||
net_pnl = float(stat.get("net_pnl", 0.0) or 0.0)
|
||||
win_rate = float(stat.get("win_rate", 0.0) or 0.0)
|
||||
return sample_size >= settings.learning_min_samples and net_pnl < 0 and win_rate <= 0.25
|
||||
|
||||
|
||||
def _should_block_stat(stat: dict[str, Any], settings: Settings) -> bool:
|
||||
sample_size = int(stat.get("sample_size", 0) or 0)
|
||||
avg_pnl = float(stat.get("average_net_pnl", 0.0) or 0.0)
|
||||
win_rate = float(stat.get("win_rate", 0.0) or 0.0)
|
||||
return sample_size >= max(settings.learning_min_samples * 3, 9) and win_rate == 0 and avg_pnl <= -0.08
|
||||
|
||||
|
||||
def _clamp(value: float, low: float, high: float) -> float:
|
||||
return max(low, min(high, value))
|
||||
Reference in New Issue
Block a user