Repository navigation
Expand file tree
/
Copy pathself_correction.py
More file actions
167 lines (144 loc) · 6.79 KB
/
Copy pathself_correction.py
File metadata and controls
167 lines (144 loc) · 6.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
"""
Self-correction module.
At the start of each cycle, evaluates the outcome of the previous actionable
trade (buy/sell) and — if it was wrong — asks Claude to generate a one-sentence
lesson that gets stored and injected into future prompts.
Outcome thresholds (applied config.OUTCOME_HORIZON_HOURS after the trade):
buy + price now -2 %+ → "wrong" (bought into a drop)
buy + price now +2 %+ → "correct"
sell + price now +2 %+ → "wrong" (sold before a rally)
sell + price now -2 %+ → "correct"
hold + |change| >3 % → "missed_opportunity"
otherwise → "neutral"
These correct/wrong/neutral LABELS drive the lesson loop + RL. Continuous
per-trade P&L (for win rate / Kelly / analytics) is computed separately in
trade_pnl.py from the entry and horizon prices stored here.
"""
from __future__ import annotations
import json
import logging
import anthropic
import config
import database as db
logger = logging.getLogger(__name__)
_client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
_data_ex = None # public production Binance — evaluation prices, never sandbox
def _market_price(symbol: str, at_ts_ms: int | None = None) -> float:
"""
Price of `symbol` at a moment in time (1h candle close), or last price
when at_ts_ms is None/now-ish. Evaluating a weeks-old backlog decision
against TODAY's price would be meaningless — outcomes are defined 4h
after the decision, so we fetch the candle at that hour.
"""
global _data_ex
if _data_ex is None:
import ccxt
_data_ex = ccxt.binance({"enableRateLimit": True, "options": {"defaultType": "spot"}})
if at_ts_ms is not None:
hour_ms = at_ts_ms - (at_ts_ms % 3_600_000)
candles = _data_ex.fetch_ohlcv(symbol, "1h", since=hour_ms, limit=1)
if candles:
return float(candles[0][4])
return float(_data_ex.fetch_ticker(symbol)["last"])
# Kept for reference / backward-compat; the live thresholds are now
# volatility-relative (see _threshold and config.OUTCOME_ATR_MULT).
WRONG_THRESHOLD_PCT = 2.0
MISSED_THRESHOLD_PCT = 3.0
def _threshold(atr_pct: float | None, mult: float) -> float:
"""
Volatility-relative outcome threshold (%): mult × the asset's hourly ATR
at trade time, clamped to [MIN, MAX]. Bigger for volatile assets, smaller
for calm ones — so "significant move" means significant *for this market*.
"""
atr = atr_pct if atr_pct and atr_pct > 0 else config.OUTCOME_FALLBACK_ATR
return max(config.OUTCOME_MIN_THRESHOLD,
min(config.OUTCOME_MAX_THRESHOLD, mult * atr))
def _outcome(action: str, trade_price: float, now_price: float,
atr_pct: float | None = None) -> tuple[str, float]:
pct = (now_price - trade_price) / trade_price * 100
wrong = _threshold(atr_pct, config.OUTCOME_ATR_MULT)
missed = _threshold(atr_pct, config.OUTCOME_MISSED_MULT)
if action == "buy":
if pct < -wrong: return "wrong", pct
if pct > wrong: return "correct", pct
elif action == "sell":
if pct > wrong: return "wrong", pct
if pct < -wrong: return "correct", pct
elif action == "hold":
if abs(pct) > missed: return "missed_opportunity", pct
return "neutral", pct
def _generate_lesson(trade: dict, pct: float, result: str, symbol: str) -> str:
# MySQL JSON columns arrive as strings — parse before .get()
m = trade.get("market") or {}
if isinstance(m, str):
m = json.loads(m or "{}")
d = trade.get("decision") or {}
if isinstance(d, str):
d = json.loads(d or "{}")
horizon = config.OUTCOME_HORIZON_HOURS
if result == "missed_opportunity":
headline = (
f"You chose to HOLD, but {symbol} moved {pct:+.1f}% over the next {horizon} h — "
f"a missed opportunity."
)
else:
headline = f"You made a wrong {trade['action']} trade."
prompt = (
f"{headline} Write ONE lesson (max 20 words) "
f"starting with 'Avoid', 'Do not', or 'Only'.\n\n"
f"Decision: {trade['action'].upper()} ${float(trade.get('amount_usd') or 0):.0f} "
f"at {symbol} ${float(trade['price']):,}\n"
f"RSI {m.get('rsi','?')} | F&G {m.get('fear_greed','?')}/100 | "
f"vs SMA20 {m.get('vs_sma20_pct','?')}%\n"
f"Your reasoning: \"{d.get('reason','')}\"\n"
f"Price moved {pct:+.1f}% over the next {horizon} h.\n\n"
f"Lesson:"
)
r = _client.messages.create(
model=config.CLAUDE_MODEL,
max_tokens=60,
messages=[{"role": "user", "content": prompt}],
)
return r.content[0].text.strip().strip('"').strip("'")
def evaluate_and_learn(current_price: float | None = None,
max_trades: int = 10) -> list[str]:
"""
Evaluate up to max_trades unevaluated decisions (buys, sells AND holds)
that are past the outcome window (config.OUTCOME_HORIZON_HOURS).
Symbol-aware: each decision is scored against its own symbol's price at
the horizon, so ETH holds are never compared to the BTC price and
week-old backlog rows are scored at their historical window, not today.
Returns the list of newly generated lessons (wrong trades and missed
opportunities both teach).
"""
trades = db.get_unevaluated_trades(limit=max_trades)
lessons: list[str] = []
for trade in trades:
market = trade.get("market") or {}
if isinstance(market, str):
market = json.loads(market or "{}")
symbol = market.get("symbol") or "BTC/USDT"
try:
eval_ts_ms = (int(trade["created_at"].timestamp() * 1000)
+ config.OUTCOME_HORIZON_HOURS * 3_600_000)
now_price = _market_price(symbol, at_ts_ms=eval_ts_ms)
except Exception as exc:
logger.warning("Outcome eval: no price for %s (trade #%s): %s",
symbol, trade["id"], exc)
continue
atr_pct = market.get("atr_pct")
result, pct = _outcome(trade["action"], float(trade["price"]), now_price,
atr_pct=float(atr_pct) if atr_pct else None)
db.update_trade_outcome(trade["id"], result, now_price)
logger.info("Trade #%s (%s %s) outcome: %s (%+.1f%%)",
trade["id"], symbol, trade["action"], result, pct)
if result in ("wrong", "missed_opportunity"):
try:
lesson = _generate_lesson(trade, pct, result, symbol)
db.save_lesson(lesson, "self_correction", trade["id"])
logger.info("New lesson: %s", lesson)
lessons.append(lesson)
except Exception as exc:
logger.warning("Lesson generation failed (trade #%s): %s",
trade["id"], exc)
return lessons