-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathtrading_bot.py
More file actions
2337 lines (2071 loc) · 118 KB
/
trading_bot.py
File metadata and controls
2337 lines (2071 loc) · 118 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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Trading Bot Core Logic - Multi-Pair Analysis
"""
Kraken Trading Bot — Core Engine
=================================
This module is the heart of the trading bot. It contains the ``TradingBot``
class that orchestrates the full trading lifecycle, plus a minimal ``Backtester``
helper for offline strategy validation.
Signal flow (high level)
------------------------
::
price_action.py (bar-pattern helpers — optional context)
│
analysis.py TechnicalAnalysis.generate_signal_with_score()
│ ↳ RSI mean-reversion (enable_mr_signals)
│ ↳ Bollinger-Band breakout (enable_trend_signals)
│ returns (signal: str, score: float [-50 … +50])
│
TradingBot.analyze_all_pairs()
│ ↳ fetches live ticker prices for all configured pairs
│ ↳ seeds price history from 60m OHLC when too sparse
│ ↳ picks the highest-scoring actionable pair
│
TradingBot.start_trading() — main loop (~60 s cycle)
│ ↳ check_take_profit_or_stop_loss() (exits first)
│ ↳ layered BUY guards (see below)
│ ↳ execute_buy_order() / execute_sell_order()
│ execute_open_short_order() / execute_close_short_order()
│
kraken_interface.py KrakenAPI.place_order()
↳ exclusive order lock (order_lock.py)
↳ exponential back-off on rate-limit errors
Layered BUY entry guards (all must pass before a buy is placed)
---------------------------------------------------------------
1. Not temporarily paused (loss-streak cooldown)
2. Daily drawdown limit not hit
3. Bear Shield not active (BTC above 4h EMA50)
4. Regime filter: BTC benchmark score ≥ regime_min_score (RISK_ON)
5. Signal score ≥ min_buy_score (default 15.0)
6. Sentiment guard: no bad-news keywords in marquee file (optional)
7. Open positions < max_open_positions
8. MTF trend (1h SMA crossover) is bullish
9. Trading hours filter (UTC window, optional)
10. Volume filter: latest 15m candle ≥ volume_filter_min_ratio × 20-candle avg
Key responsibilities of TradingBot
-----------------------------------
- Maintains per-pair state: holdings, entry price, peak price, stop levels,
short positions, trade metrics, cooldown timestamps.
- Reconciles holdings and average entry price from Kraken trade history on
startup/restart (``load_purchase_prices_from_history``).
- Hot-reloads ``config.toml`` every 5 minutes — no restart needed for tweaks.
- Writes structured JSONL trade events to ``logs/trade_events.jsonl`` and a
human-readable CSV to ``reports/trade_journal.csv``.
- Persists the price-history buffer to ``data/history_buffer.json`` so RSI/SMA
indicators survive a bot restart without a warm-up gap.
- NAS paths (trade history, OHLC archives) are resolved via ``utils.nas_paths()``.
Usage (called from main.py)
---------------------------
::
from trading_bot import TradingBot
bot = TradingBot(api_client, config)
bot.start_trading()
"""
import json
import logging
import time
import os
from datetime import datetime, timezone
from pathlib import Path
from analysis import TechnicalAnalysis
from utils import load_config
# Load .env if python-dotenv is available (graceful fallback otherwise)
try:
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")
except ImportError:
_env_path = Path(__file__).parent / ".env"
if _env_path.exists():
for _line in _env_path.read_text().splitlines():
if "=" in _line and not _line.startswith("#"):
_k, _v = _line.split("=", 1)
os.environ.setdefault(_k.strip(), _v.strip())
from core import notifier as _notifier
# NAS root — read from config [paths] nas_root, fallback to default mount point
def _resolve_nas_root(config: dict) -> Path:
return Path(config.get('paths', {}).get('nas_root', '/mnt/fritz_nas/Volume/kraken'))
_TRADE_HISTORY_REFRESH_INTERVAL = 600 # seconds between Kraken API fetches (10 min)
def _sd_notify_watchdog() -> None:
"""Send WATCHDOG=1 ping to systemd via the NOTIFY_SOCKET (no extra packages needed)."""
import socket
sock_path = os.environ.get("NOTIFY_SOCKET")
if not sock_path:
return
try:
addr = "\0" + sock_path[1:] if sock_path.startswith("@") else sock_path
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as s:
s.sendto(b"WATCHDOG=1", addr)
except Exception:
pass
class TradingBot:
def __init__(self, api_client, config):
self.api_client = api_client
self.config = config
self.config_path = os.path.join(os.path.dirname(__file__), 'config.toml')
self.logger = logging.getLogger(__name__)
self.nas_root = _resolve_nas_root(config)
self.analysis_tool = TechnicalAnalysis(rsi_period=14, sma_short=20, sma_long=50)
# Signal engine mode: mean-reversion (reversion_bias) and/or trend/breakout (BB)
self.enable_mr_signals = bool(self.config.get('risk_management', {}).get('enable_mean_reversion_signals', True))
self.enable_trend_signals = bool(self.config.get('risk_management', {}).get('enable_trend_breakout_signals', True))
self.mr_rsi_oversold = float(self.config.get('risk_management', {}).get('mr_rsi_oversold_threshold', 33.0))
self.mr_rsi_overbought = float(self.config.get('risk_management', {}).get('mr_rsi_overbought_threshold', 67.0))
self.analysis_tool.enable_mr_signals = self.enable_mr_signals
self.analysis_tool.enable_trend_signals = self.enable_trend_signals
self.analysis_tool.mr_rsi_buy = self.mr_rsi_oversold
self.analysis_tool.mr_rsi_sell = self.mr_rsi_overbought
self.trade_pairs = self.config['bot_settings'].get('trade_pairs', ['XBTEUR'])
self.pair_signals = {}
self.pair_prices = {}
self.pair_scores = {}
self.holdings = {}
self.purchase_prices = {}
self.peak_prices = {}
self.position_qty = {}
self.short_qty = {}
self.short_entry_prices = {}
self.realized_pnl = {}
self.fees_paid = {}
self.trade_metrics = {}
self.closed_trade_pnls = []
self.last_trade_at = {}
self.entry_timestamps = {}
self.last_global_trade_at = 0
self._normalized_pair_logs_seen = set()
self._last_empty_sell_log_at = {}
self._load_cooldown_state()
self.trade_count = 0
self.consecutive_losses = 0
self.trading_paused_until_ts = 0
self.target_balance_eur = self._get_target_balance()
# stop info per pair (stop_price, type)
self.stop_info = {}
# journaling path
self.journal_path = os.path.join(os.path.dirname(__file__), 'reports', 'trade_journal.csv')
# structured JSONL trade log for observability
self.json_journal_path = os.path.join(os.path.dirname(__file__), 'logs', 'trade_events.jsonl')
os.makedirs(os.path.dirname(self.json_journal_path), exist_ok=True)
# manual kill-switch file: if present, bot will pause buys
self.kill_switch_path = os.path.join(os.path.dirname(__file__), 'PAUSE')
self.take_profit_percent = self._get_take_profit_percent()
self.stop_loss_percent = self._get_stop_loss_percent()
self.max_open_positions = int(self.config.get('risk_management', {}).get('max_open_positions', 3))
self.trade_cooldown_sec = int(self.config.get('risk_management', {}).get('trade_cooldown_seconds', 180))
self.global_trade_cooldown_sec = int(self.config.get('risk_management', {}).get('global_trade_cooldown_seconds', 300))
self.trailing_stop_percent = float(self.config.get('risk_management', {}).get('trailing_stop_percent', 1.5))
self.min_buy_score = float(self.config.get('risk_management', {}).get('min_buy_score', 18.0))
self.adaptive_tp_enabled = bool(self.config.get('risk_management', {}).get('adaptive_take_profit', True))
self.max_tp_percent = float(self.config.get('risk_management', {}).get('max_take_profit_percent', 14.0))
self.sell_fee_buffer_percent = float(self.config.get('risk_management', {}).get('sell_fee_buffer_percent', 0.0))
self.empty_sell_log_cooldown_sec = int(self.config.get('risk_management', {}).get('empty_sell_log_cooldown_seconds', 1800))
# ATR stop config
self.enable_atr_stop = bool(self.config.get('risk_management', {}).get('enable_atr_stop', False))
self.atr_period = int(self.config.get('risk_management', {}).get('atr_period', 14))
self.atr_multiplier = float(self.config.get('risk_management', {}).get('atr_multiplier', 1.5))
self.atr_trail_multiplier = float(self.config.get('risk_management', {}).get('atr_trail_multiplier', 0.75))
# ATR dynamic take-profit: TP floor = atr_tp_multiplier × ATR%
self.enable_atr_dynamic_tp = bool(self.config.get('risk_management', {}).get('enable_atr_dynamic_tp', False))
self.atr_tp_multiplier = float(self.config.get('risk_management', {}).get('atr_tp_multiplier', 2.0))
# Break-even stop-loss
self.enable_break_even = bool(self.config.get('risk_management', {}).get('enable_break_even', True))
self.break_even_trigger_pct = float(self.config.get('risk_management', {}).get('break_even_trigger_percent', 1.5))
# pyramiding
self.enable_pyramiding = bool(self.config.get('risk_management', {}).get('enable_pyramiding', False))
self.pyramiding_add_pct = float(self.config.get('risk_management', {}).get('pyramiding_add_pct', 0.5))
self.enable_regime_filter = bool(self.config.get('risk_management', {}).get('enable_regime_filter', True))
self.regime_benchmark_pair = str(self.config.get('risk_management', {}).get('regime_benchmark_pair', 'XBTEUR')).upper()
self.regime_min_score = float(self.config.get('risk_management', {}).get('regime_min_score', -5.0))
self.enable_hard_stop_loss = bool(self.config.get('risk_management', {}).get('enable_hard_stop_loss', True))
self.hard_stop_loss_percent = float(self.config.get('risk_management', {}).get('hard_stop_loss_percent', 4.0))
self.enable_mtf_regime_scoring = bool(self.config.get('risk_management', {}).get('enable_mtf_regime_scoring', True))
self.mtf_regime_min_score = float(self.config.get('risk_management', {}).get('mtf_regime_min_score', -2.0))
self.enable_time_stop = bool(self.config.get('risk_management', {}).get('enable_time_stop', True))
self.time_stop_hours = int(self.config.get('risk_management', {}).get('time_stop_hours', 72))
self.enable_daily_drawdown = bool(self.config.get('risk_management', {}).get('enable_daily_drawdown', True))
self.daily_drawdown_percent = float(self.config.get('risk_management', {}).get('daily_loss_limit_percent', 3.0))
self.risk_off_allocation_multiplier = float(self.config.get('risk_management', {}).get('risk_off_allocation_multiplier', 0.35))
self.enable_volatility_targeting = bool(self.config.get('risk_management', {}).get('enable_volatility_targeting', True))
self.target_volatility_pct = float(self.config.get('risk_management', {}).get('target_volatility_pct', 1.6))
self.max_consecutive_losses = int(self.config.get('risk_management', {}).get('max_consecutive_losses', 3))
self.pause_after_loss_streak_minutes = int(self.config.get('risk_management', {}).get('pause_after_loss_streak_minutes', 180))
self.enable_live_shorts = bool(self.config.get('shorting', {}).get('enabled', False))
self.short_leverage = str(self.config.get('shorting', {}).get('leverage', '2'))
self.max_short_notional_eur = float(self.config.get('shorting', {}).get('max_short_notional_eur', 50.0))
self.short_take_profit_percent = float(self.config.get('shorting', {}).get('short_take_profit_percent', 2.5))
self.short_stop_loss_percent = float(self.config.get('shorting', {}).get('short_stop_loss_percent', 3.0))
# Fast scalp / hit-and-run profile
self.enable_fast_scalp = bool(self.config.get('profiles', {}).get('fast_scalp', {}).get('enabled', False))
self.fast_scalp_require_flag = bool(self.config.get('profiles', {}).get('fast_scalp', {}).get('require_enable_flag', True))
self.fast_scalp_time_stop_minutes = int(self.config.get('profiles', {}).get('fast_scalp', {}).get('time_stop_minutes', 30))
self.fast_scalp_stop_loss_pct = float(self.config.get('profiles', {}).get('fast_scalp', {}).get('stop_loss_percent', 0.6))
self.fast_scalp_take_profit_pct = float(self.config.get('profiles', {}).get('fast_scalp', {}).get('take_profit_percent', 1.2))
self.start_time = datetime.now()
self.last_config_reload = datetime.now()
self.config_reload_interval = 300
self.loop_interval_sec = int(self.config.get('bot_settings', {}).get('loop_interval_seconds', 60))
self.daily_start_balance = None
self.initial_balance_eur = None
self.start_timestamp = int(time.time())
self.net_deposits_eur = 0.0
self.net_withdrawals_eur = 0.0
self._last_cashflow_refresh_ts = 0
self.cashflow_refresh_interval_sec = int(self.config.get('reporting', {}).get('cashflow_refresh_seconds', 600))
if self.cashflow_refresh_interval_sec > 300:
self.logger.warning(
f"cashflow_refresh_seconds is {self.cashflow_refresh_interval_sec}s (>5m). "
f"Deposits/withdrawals may not be reflected for up to {self.cashflow_refresh_interval_sec}s. "
f"Consider setting cashflow_refresh_seconds = 60 in config.toml [reporting]."
)
self.last_daily_reset_ts = int(time.time())
self.valid_pairs = self._fetch_valid_trade_pairs(self.trade_pairs)
self.trade_pairs = self.valid_pairs if self.valid_pairs else []
self._init_pair_state(self.trade_pairs)
# Flash-crash airbag tracking: {pair: [(timestamp, price), ...]}
self.price_history_airbag = {p: [] for p in self.trade_pairs}
self.airbag_drop_threshold = float(self.config.get('risk_management', {}).get('airbag_drop_threshold', 15.0))
self.airbag_window_minutes = int(self.config.get('risk_management', {}).get('airbag_window_minutes', 10))
# Sentiment integration (opt-in)
self.enable_sentiment_guard = bool(self.config.get('risk_management', {}).get('enable_sentiment_guard', False))
self.news_marquee_path = "/tmp/youtube_stream/news_marquee.txt"
self.sentiment_pause_keywords = ["crash", "hack", "dump", "sec", "lawsuit", "regulation", "ban"]
self.sentiment_active = False
# Time-of-day filter: only open new positions during high-volume hours (UTC)
self.enable_trading_hours = bool(self.config.get('risk_management', {}).get('enable_trading_hours', True))
self.trading_hours_start_utc = int(self.config.get('risk_management', {}).get('trading_hours_start_utc', 14))
self.trading_hours_end_utc = int(self.config.get('risk_management', {}).get('trading_hours_end_utc', 22))
# Volume filter: skip entries when volume is unusually low
self.enable_volume_filter = bool(self.config.get('risk_management', {}).get('enable_volume_filter', True))
self.volume_filter_min_ratio = float(self.config.get('risk_management', {}).get('volume_filter_min_ratio', 0.5))
self._volume_cache = {} # {pair: (timestamp, ratio)}
# Bear Shield: auto-park in FIAT during confirmed downtrends
bear_cfg = self.config.get('bear_shield', {})
self.enable_bear_shield = bool(bear_cfg.get('enable_bear_shield', False))
self.bear_ema_period = int(bear_cfg.get('bear_ema_period', 50))
self.bear_confirm_candles = int(bear_cfg.get('bear_confirm_candles', 3))
self.bear_benchmark_pair = str(bear_cfg.get('bear_benchmark_pair', 'XETHZEUR')).upper()
self.bear_log_interval_minutes = int(bear_cfg.get('bear_log_interval_minutes', 60))
self._bear_mode_active = False # current state
self._bear_last_log_ts = 0 # throttle logging
# Trade history cache: avoids hitting Kraken API every loop iteration
self._trade_history_cache: dict = {} # {trade_id: trade_dict}
self._trade_history_last_fetch: float = 0.0 # unix timestamp of last API fetch
def _notify_pause(self, reason):
"""Log and attempt to notify an external channel when a trading pause activates."""
try:
import json, subprocess, datetime, os
logp = os.path.join(os.path.dirname(__file__), 'logs', 'pause_events.log')
os.makedirs(os.path.dirname(logp), exist_ok=True)
entry = {
'ts': datetime.datetime.utcnow().isoformat(),
'reason': reason,
'balance': float(self.get_eur_balance()),
'consecutive_losses': int(getattr(self,'consecutive_losses',0))
}
with open(logp,'a') as f:
f.write(json.dumps(entry) + "\n")
# call optional notifier script
script = os.path.join(os.path.dirname(__file__), 'scripts', 'notify_pause.sh')
if os.path.exists(script) and os.access(script, os.X_OK):
try:
subprocess.Popen([script, reason], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
self.logger.debug(f"notify_pause: could not run notifier script: {e}")
except Exception as e:
self.logger.warning(f"notify_pause: failed to write pause log: {e}")
# ── Bear Shield ───────────────────────────────────────────────────────────
def _calc_ema(self, prices, period):
"""Simple EMA calculation (no external dependencies)."""
if len(prices) < period:
return None
k = 2.0 / (period + 1)
ema = prices[0]
for p in prices[1:]:
ema = p * k + ema * (1 - k)
return ema
def _is_bear_market(self):
"""Return True when the 4h trend has confirmed a downtrend for bear_confirm_candles.
Logic: fetch 4h OHLC for bear_benchmark_pair, compute EMA(bear_ema_period).
If the last bear_confirm_candles closes are ALL below EMA → bear mode.
If price crosses back above EMA → bull mode restored.
Fails safe: returns False (allow trading) if API call fails.
"""
if not self.enable_bear_shield:
return False
try:
ohlc = self.api_client.get_ohlc_data(self.bear_benchmark_pair, interval=240) # 4h
if not ohlc:
return False
key = [k for k in ohlc.keys() if k != 'last']
if not key:
return False
rows = ohlc[key[0]]
closes = [float(r[4]) for r in rows if r and len(r) >= 5]
if len(closes) < self.bear_ema_period + self.bear_confirm_candles:
return False
ema = self._calc_ema(closes[:-self.bear_confirm_candles], self.bear_ema_period)
if ema is None:
return False
# Check last N candles are all below EMA
last_n = closes[-self.bear_confirm_candles:]
return all(c < ema for c in last_n)
except Exception as e:
self.logger.debug(f"Bear shield check failed (safe fallback to False): {e}")
return False
def _bear_shield_exit_all(self):
"""Sell all open long positions to park in FIAT (bear market escape)."""
sold_any = False
for pair in list(self.trade_pairs):
qty = self.holdings.get(pair, 0.0)
min_vol = self._get_min_volume(pair)
if qty >= min_vol:
price = self.pair_prices.get(pair, 0.0)
if price > 0:
self.logger.warning(
f"BEAR SHIELD: selling {qty:.6f} {pair} @ {price:.4f} EUR to park in FIAT"
)
self.execute_sell_order(pair, price)
sold_any = True
return sold_any
def _update_airbag_history(self, pair, price):
"""Append (timestamp, price) to the rolling flash-crash window for *pair*.
The window is kept to the last ``airbag_window_minutes`` minutes.
Called every cycle from ``analyze_all_pairs`` before the airbag check.
"""
now = time.time()
history = self.price_history_airbag.get(pair, [])
history.append((now, price))
# Remove old entries
cutoff = now - (self.airbag_window_minutes * 60)
self.price_history_airbag[pair] = [h for h in history if h[0] >= cutoff]
def _check_airbag_trigger(self, pair):
"""Return True if price has dropped ≥ airbag_drop_threshold% within the airbag window.
When triggered, the caller (``analyze_all_pairs``) immediately issues a
market sell to exit the position — this is the "flash-crash airbag".
Requires at least 2 data points; returns False if insufficient history.
"""
history = self.price_history_airbag.get(pair, [])
if len(history) < 2:
return False
peak_price = max(h[1] for h in history)
current_price = history[-1][1]
drop = ((peak_price - current_price) / peak_price) * 100.0
if drop >= self.airbag_drop_threshold:
self.logger.critical(f"AIRBAG TRIGGERED for {pair}: drop of {drop:.2f}% in {self.airbag_window_minutes}m")
return True
return False
def _scan_news_sentiment(self):
try:
if not os.path.exists(self.news_marquee_path):
return False
import re, fcntl
with open(self.news_marquee_path, 'r') as f:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB)
content = f.read().lower()
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except (OSError, BlockingIOError):
# File is being written to; skip this cycle safely
return self.sentiment_active # Keep previous state
# Use word boundaries to avoid false positives (like 'sec' in 'secretary')
found = [k for k in self.sentiment_pause_keywords if re.search(r'\b' + re.escape(k) + r'\b', content)]
if found:
if not self.sentiment_active:
self.logger.warning(f"SENTIMENT GUARD: Keywords found in news ({', '.join(found)}). Pausing Buys.")
return True
return False
except Exception:
return False
def _init_pair_state(self, pairs):
"""Initialise all per-pair state dicts for newly added pairs.
Called once at startup for all configured pairs and again whenever
``reload_config`` detects that new pairs have been added to the config.
Safe to call multiple times — ``setdefault`` prevents overwriting
existing state for pairs that are already active.
"""
for pair in pairs:
self.pair_signals.setdefault(pair, "HOLD")
self.holdings.setdefault(pair, 0.0)
self.purchase_prices.setdefault(pair, 0.0)
self.peak_prices.setdefault(pair, 0.0)
self.position_qty.setdefault(pair, 0.0)
self.short_qty.setdefault(pair, 0.0)
self.short_entry_prices.setdefault(pair, 0.0)
self.realized_pnl.setdefault(pair, 0.0)
self.fees_paid.setdefault(pair, 0.0)
self.trade_metrics.setdefault(pair, {"closed": 0, "wins": 0, "losses": 0, "sum_pnl": 0.0})
self.last_trade_at.setdefault(pair, 0)
self.entry_timestamps.setdefault(pair, None)
def _get_target_balance(self):
try:
return self.config['bot_settings']['trade_amounts'].get('target_balance_eur', 1000.0)
except Exception:
return self.config['bot_settings'].get('target_balance_eur', 1000.0)
def _get_take_profit_percent(self):
try:
return float(self.config['risk_management'].get('take_profit_percent', 5.0))
except Exception:
return 5.0
def _get_stop_loss_percent(self):
try:
return float(self.config['risk_management'].get('stop_loss_percent', 2.0))
except Exception:
return 2.0
def _get_trade_amount_eur(self):
try:
return float(self.config['bot_settings']['trade_amounts'].get('trade_amount_eur', 30.0))
except Exception:
return 30.0
def _get_dynamic_trade_amount_eur(self, pair, available_eur):
"""Dynamic sizing: adjusted by ATR volatility and available EUR."""
base_amount = self._get_trade_amount_eur()
# 1. Start with percentage-based sizing
allocation_pct = float(self.config.get('risk_management', {}).get('allocation_per_trade_percent', 10.0))
amount = available_eur * (allocation_pct / 100.0)
# 2. ATR adjustment (Vol Targeting)
# We target a specific % movement per trade.
atr = self.analysis_tool.calculate_atr(pair)
current_price = self.pair_prices.get(pair, 0)
if atr and current_price > 0:
# How many units to buy so that 1 ATR movement = X% of trade
# Normalizing factor: higher volatility -> lower amount
volatility_ratio = (atr / current_price) * 100.0
# Reference: 1.5% ATR is "normal". If vol is 3%, we halve the size.
target_vol = 1.5
vol_multiplier = target_vol / max(0.5, volatility_ratio)
amount *= vol_multiplier
# 3. Apply risk-off multiplier from regime
amount *= self._allocation_multiplier()
# Cap at configured max base amount and available funds
return min(base_amount * 1.5, amount, available_eur * 0.95)
def _is_mtf_trend_bullish(self, pair):
"""Check 1h timeframe to confirm bullish trend."""
try:
ohlc = self.api_client.get_ohlc_data(pair, interval=60) # 1h
if not ohlc:
return True # Fallback to allow trade if API fails
data_key = list(ohlc.keys())[0]
# Kraken returns [time, open, high, low, close, vwap, volume, count]
closes = [float(row[4]) for row in ohlc[data_key]]
return self.analysis_tool.check_mtf_trend(closes)
except Exception as e:
self.logger.error(f"MTF check failed for {pair}: {e}")
return True
def _get_min_volume(self, pair):
try:
min_volumes = self.config['bot_settings'].get('min_volumes', {})
if pair in min_volumes:
return float(min_volumes.get(pair, 0.0001))
# alias fallback (altname <-> wsname style)
aliases = {
'XBTEUR': 'XXBTZEUR',
'ETHEUR': 'XETHZEUR',
'XRPEUR': 'XXRPZEUR',
'XXBTZEUR': 'XBTEUR',
'XETHZEUR': 'ETHEUR',
'XXRPZEUR': 'XRPEUR',
}
alt = aliases.get(pair)
if alt and alt in min_volumes:
return float(min_volumes.get(alt, 0.0001))
return 0.0001
except Exception:
return 0.0001
def _calculate_volume(self, pair, price, available_eur=None):
trade_amount_eur = self._get_trade_amount_eur()
if available_eur is not None:
trade_amount_eur = min(trade_amount_eur, max(0.0, available_eur))
min_volume = self._get_min_volume(pair)
if price <= 0:
return 0.0
calculated_volume = trade_amount_eur / price
return max(calculated_volume, min_volume)
def _fetch_valid_trade_pairs(self, requested_pairs):
assets = self.api_client.get_asset_pairs()
if not assets:
self.logger.warning("Could not fetch AssetPairs; using configured pairs unchanged")
return requested_pairs
valid_requested = []
seen = set()
# Build flexible normalization index (ALTNAME, WSNAME, and slashless variants)
pair_index = {}
for key, meta in assets.items():
alt = (meta.get('altname') or key or '').upper()
ws = (meta.get('wsname') or '').upper()
ws_noslash = ws.replace('/', '')
key_u = (key or '').upper()
for alias in [alt, ws, ws_noslash, key_u, alt.replace('/', '')]:
if alias:
pair_index[alias] = alt
for raw_pair in requested_pairs:
pair = (raw_pair or '').upper()
normalized = pair_index.get(pair) or pair_index.get(pair.replace('/', ''))
if normalized:
if normalized not in seen:
valid_requested.append(normalized)
seen.add(normalized)
if pair != normalized:
normalization_key = f"{pair}->{normalized}"
if normalization_key not in self._normalized_pair_logs_seen:
self.logger.info(f"Pair normalized: {pair} -> {normalized}")
self._normalized_pair_logs_seen.add(normalization_key)
else:
self.logger.warning(f"Skipping unknown Kraken pair: {raw_pair}")
self.kelly_fraction = self._calculate_kelly_fraction()
if not valid_requested:
self.logger.error("No valid trading pairs after Kraken validation")
else:
self.logger.info(f"Validated trading pairs: {valid_requested}")
return valid_requested
def reload_config(self):
"""Hot-reload config.toml and apply all changed settings without restarting.
Called automatically every ``config_reload_interval`` seconds from the
main loop. Detects newly added trade pairs and initialises their state.
Existing holdings and entry prices are preserved across reloads.
Returns True on success, False if the config file cannot be parsed.
"""
try:
new_config = load_config(self.config_path)
if not new_config:
return False
old_pairs = set(self.trade_pairs)
self.config = new_config
requested = self.config['bot_settings'].get('trade_pairs', ['XBTEUR'])
self.trade_pairs = self._fetch_valid_trade_pairs(requested)
new_pairs = set(self.trade_pairs)
# Only initialise state for truly NEW pairs; preserve holdings/entry-prices for existing ones
added_pairs = list(new_pairs - old_pairs)
if added_pairs:
self._init_pair_state(added_pairs)
# Immediately reconcile live state so no stale holdings data lingers
self._sync_account_state()
self.target_balance_eur = self._get_target_balance()
self.take_profit_percent = self._get_take_profit_percent()
self.stop_loss_percent = self._get_stop_loss_percent()
self.max_open_positions = int(self.config.get('risk_management', {}).get('max_open_positions', self.max_open_positions))
self.trade_cooldown_sec = int(self.config.get('risk_management', {}).get('trade_cooldown_seconds', self.trade_cooldown_sec))
self.global_trade_cooldown_sec = int(self.config.get('risk_management', {}).get('global_trade_cooldown_seconds', self.global_trade_cooldown_sec))
self.trailing_stop_percent = float(self.config.get('risk_management', {}).get('trailing_stop_percent', self.trailing_stop_percent))
self.empty_sell_log_cooldown_sec = int(self.config.get('risk_management', {}).get('empty_sell_log_cooldown_seconds', self.empty_sell_log_cooldown_sec))
self.enable_regime_filter = bool(self.config.get('risk_management', {}).get('enable_regime_filter', self.enable_regime_filter))
self.regime_benchmark_pair = str(self.config.get('risk_management', {}).get('regime_benchmark_pair', self.regime_benchmark_pair)).upper()
self.regime_min_score = float(self.config.get('risk_management', {}).get('regime_min_score', self.regime_min_score))
self.enable_hard_stop_loss = bool(self.config.get('risk_management', {}).get('enable_hard_stop_loss', self.enable_hard_stop_loss))
self.hard_stop_loss_percent = float(self.config.get('risk_management', {}).get('hard_stop_loss_percent', self.hard_stop_loss_percent))
self.enable_mtf_regime_scoring = bool(self.config.get('risk_management', {}).get('enable_mtf_regime_scoring', self.enable_mtf_regime_scoring))
self.mtf_regime_min_score = float(self.config.get('risk_management', {}).get('mtf_regime_min_score', self.mtf_regime_min_score))
self.enable_time_stop = bool(self.config.get('risk_management', {}).get('enable_time_stop', self.enable_time_stop))
self.time_stop_hours = int(self.config.get('risk_management', {}).get('time_stop_hours', self.time_stop_hours))
self.enable_daily_drawdown = bool(self.config.get('risk_management', {}).get('enable_daily_drawdown', self.enable_daily_drawdown))
self.daily_drawdown_percent = float(self.config.get('risk_management', {}).get('daily_loss_limit_percent', self.daily_drawdown_percent))
self.risk_off_allocation_multiplier = float(self.config.get('risk_management', {}).get('risk_off_allocation_multiplier', self.risk_off_allocation_multiplier))
self.enable_volatility_targeting = bool(self.config.get('risk_management', {}).get('enable_volatility_targeting', self.enable_volatility_targeting))
self.target_volatility_pct = float(self.config.get('risk_management', {}).get('target_volatility_pct', self.target_volatility_pct))
self.max_consecutive_losses = int(self.config.get('risk_management', {}).get('max_consecutive_losses', self.max_consecutive_losses))
self.pause_after_loss_streak_minutes = int(self.config.get('risk_management', {}).get('pause_after_loss_streak_minutes', self.pause_after_loss_streak_minutes))
self.sell_fee_buffer_percent = float(self.config.get('risk_management', {}).get('sell_fee_buffer_percent', self.sell_fee_buffer_percent))
self.enable_sentiment_guard = bool(self.config.get('risk_management', {}).get('enable_sentiment_guard', self.enable_sentiment_guard))
# Signal engine mode reload
self.enable_mr_signals = bool(self.config.get('risk_management', {}).get('enable_mean_reversion_signals', self.enable_mr_signals))
self.enable_trend_signals = bool(self.config.get('risk_management', {}).get('enable_trend_breakout_signals', self.enable_trend_signals))
self.mr_rsi_oversold = float(self.config.get('risk_management', {}).get('mr_rsi_oversold_threshold', self.mr_rsi_oversold))
self.mr_rsi_overbought = float(self.config.get('risk_management', {}).get('mr_rsi_overbought_threshold', self.mr_rsi_overbought))
self.analysis_tool.enable_mr_signals = self.enable_mr_signals
self.analysis_tool.enable_trend_signals = self.enable_trend_signals
self.analysis_tool.mr_rsi_buy = self.mr_rsi_oversold
self.analysis_tool.mr_rsi_sell = self.mr_rsi_overbought
# ATR + pyramiding reload
self.enable_atr_stop = bool(self.config.get('risk_management', {}).get('enable_atr_stop', self.enable_atr_stop))
self.atr_period = int(self.config.get('risk_management', {}).get('atr_period', self.atr_period))
self.atr_multiplier = float(self.config.get('risk_management', {}).get('atr_multiplier', self.atr_multiplier))
self.atr_trail_multiplier = float(self.config.get('risk_management', {}).get('atr_trail_multiplier', self.atr_trail_multiplier))
self.enable_atr_dynamic_tp = bool(self.config.get('risk_management', {}).get('enable_atr_dynamic_tp', self.enable_atr_dynamic_tp))
self.atr_tp_multiplier = float(self.config.get('risk_management', {}).get('atr_tp_multiplier', self.atr_tp_multiplier))
self.enable_break_even = bool(self.config.get('risk_management', {}).get('enable_break_even', self.enable_break_even))
self.break_even_trigger_pct = float(self.config.get('risk_management', {}).get('break_even_trigger_percent', self.break_even_trigger_pct))
self.enable_pyramiding = bool(self.config.get('risk_management', {}).get('enable_pyramiding', self.enable_pyramiding))
self.pyramiding_add_pct = float(self.config.get('risk_management', {}).get('pyramiding_add_pct', self.pyramiding_add_pct))
if old_pairs != new_pairs:
self.logger.info(f"CONFIG RELOAD: trade_pairs changed {sorted(old_pairs)} -> {sorted(new_pairs)}")
# Bear Shield reload
bear_cfg = self.config.get('bear_shield', {})
self.enable_bear_shield = bool(bear_cfg.get('enable_bear_shield', self.enable_bear_shield))
self.bear_ema_period = int(bear_cfg.get('bear_ema_period', self.bear_ema_period))
self.bear_confirm_candles = int(bear_cfg.get('bear_confirm_candles', self.bear_confirm_candles))
self.bear_benchmark_pair = str(bear_cfg.get('bear_benchmark_pair', self.bear_benchmark_pair)).upper()
self.bear_log_interval_minutes = int(bear_cfg.get('bear_log_interval_minutes', self.bear_log_interval_minutes))
self.last_config_reload = datetime.now()
self.loop_interval_sec = int(self.config.get('bot_settings', {}).get('loop_interval_seconds', self.loop_interval_sec))
return True
except Exception as e:
self.logger.error(f"Error reloading config: {e}")
return False
def get_eur_balance(self):
"""Return current EUR (ZEUR) balance from Kraken; returns 0.0 on error."""
try:
balance = self.api_client.get_account_balance()
if balance:
return float(balance.get('ZEUR', 0))
return 0.0
except Exception as e:
self.logger.error(f"Error getting EUR balance: {e}")
return 0.0
def get_crypto_holdings(self):
"""Refresh ``self.holdings`` dict from Kraken account balance.
Maps Kraken asset codes (e.g. 'XXBT') back to our pair keys
(e.g. 'XBTEUR'). Only updates pairs listed in ``self.trade_pairs``.
"""
try:
balance = self.api_client.get_account_balance()
if not balance:
return
pair_to_balance = {
'XBTEUR': 'XXBT', 'XXBTZEUR': 'XXBT',
'ETHEUR': 'XETH', 'XETHZEUR': 'XETH',
'SOLEUR': 'SOL',
'ADAEUR': 'ADA',
'DOTEUR': 'DOT',
'XRPEUR': 'XXRP', 'XXRPZEUR': 'XXRP',
'LINKEUR': 'LINK',
'MATICEUR': 'MATIC',
'POLEUR': 'POL'
}
for pair in self.trade_pairs:
key = pair_to_balance.get(pair)
if not key:
continue
self.holdings[pair] = float(balance.get(key, 0))
except Exception as e:
self.logger.error(f"Error getting holdings: {e}")
def _reconcile_open_orders(self):
"""Compare open orders on Kraken with local position state at startup.
Detects 'orphaned' orders that exist on Kraken but are not reflected
locally (e.g. bot died between placing an order and updating state).
Logs a warning so the operator can decide to cancel manually if needed.
"""
try:
open_orders_result = self.api_client.get_open_orders()
if not open_orders_result:
return
open_map = open_orders_result.get('open', open_orders_result) if isinstance(open_orders_result, dict) else {}
if not open_map:
return
watched = set(self.trade_pairs)
# Build alias map so we can match Kraken pair names to our normalised pairs
pair_aliases = {
'XXBTZEUR': 'XBTEUR', 'XBTEUR': 'XBTEUR',
'XETHZEUR': 'ETHEUR', 'ETHEUR': 'ETHEUR',
'SOLEUR': 'SOLEUR', 'ADAEUR': 'ADAEUR',
'DOTEUR': 'DOTEUR',
'XXRPZEUR': 'XRPEUR', 'XRPEUR': 'XRPEUR',
'LINKEUR': 'LINKEUR',
}
for txid, order in open_map.items():
raw_pair = str(order.get('descr', {}).get('pair', '') or order.get('pair', '')).upper()
norm_pair = pair_aliases.get(raw_pair, raw_pair)
if norm_pair not in watched:
continue
side = str(order.get('descr', {}).get('type', '') or '').lower()
vol = float(order.get('vol', 0) or 0)
local_holding = self.holdings.get(norm_pair, 0.0)
local_short = self.short_qty.get(norm_pair, 0.0)
# Check for mismatches
if side == 'buy' and local_holding < self._get_min_volume(norm_pair):
self.logger.warning(
f"RECONCILE: Open BUY order {txid} ({vol:.6f} {norm_pair}) exists on Kraken "
f"but local holdings={local_holding:.8f}. Bot may have crashed before state update."
)
elif side == 'sell' and local_short <= 0 and local_holding < self._get_min_volume(norm_pair):
self.logger.warning(
f"RECONCILE: Open SELL order {txid} ({vol:.6f} {norm_pair}) exists on Kraken "
f"but no local long/short position found."
)
self.logger.info(f"Order reconciliation complete. {len(open_map)} open order(s) checked.")
except Exception as e:
self.logger.error(f"Order reconciliation failed: {e}", exc_info=True)
def _sync_account_state(self, force_history: bool = False):
"""Refresh local holdings and purchase-price state from the Kraken API.
Called after every trade and at startup. When ``force_history=True``
(post-trade or on first boot) it bypasses the 10-minute cache and
re-fetches the full trade history from Kraken / NAS to recompute the
average entry price.
"""
self.get_crypto_holdings()
self.load_purchase_prices_from_history(force=force_history)
def _place_live_order(self, pair, direction, volume, price=None, leverage=None, post_only=False, reduce_only=False):
"""Place a live order using the configured execution path.
If limit fallback is enabled, wait for the order to fill (or be
cancelled + replaced with market on timeout) before returning. This
prevents the bot from treating a merely accepted post-only order as an
executed trade, which otherwise can cause duplicate SELLs / phantom
drawdown when funds are temporarily reserved.
"""
exec_cfg = self.config.get('execution', {}) if isinstance(self.config, dict) else {}
use_fallback = bool(exec_cfg.get('enable_live_limit_fallback', True))
timeout_sec = int(exec_cfg.get('limit_fallback_timeout_sec', 30))
if use_fallback:
return self.api_client.place_order_with_fallback(
pair=pair,
direction=direction,
volume=volume,
price=price,
leverage=leverage,
post_only=post_only,
reduce_only=reduce_only,
timeout_sec=timeout_sec,
)
return self.api_client.place_order(
pair=pair,
direction=direction,
volume=volume,
price=price,
leverage=leverage,
post_only=post_only,
reduce_only=reduce_only,
)
def _get_open_orders_snapshot(self):
"""Return normalized open-order metadata keyed by Kraken txid.
Normalizes pair aliases and computes remaining volume so callers can
reason about pending orders without duplicating Kraken response parsing.
"""
try:
open_orders_result = self.api_client.get_open_orders()
if not open_orders_result:
return {}
open_map = open_orders_result.get('open', open_orders_result) if isinstance(open_orders_result, dict) else {}
if not isinstance(open_map, dict) or not open_map:
return {}
pair_aliases = {
'XXBTZEUR': 'XBTEUR', 'XBTEUR': 'XBTEUR',
'XETHZEUR': 'ETHEUR', 'ETHEUR': 'ETHEUR',
'SOLEUR': 'SOLEUR',
'ADAEUR': 'ADAEUR',
'DOTEUR': 'DOTEUR',
'XXRPZEUR': 'XRPEUR', 'XRPEUR': 'XRPEUR',
'LINKEUR': 'LINKEUR',
'MATICEUR': 'MATICEUR',
'POLEUR': 'POLEUR',
}
normalized = {}
for txid, order in open_map.items():
descr = order.get('descr', {}) if isinstance(order, dict) else {}
side = str(descr.get('type', '') or order.get('type', '') or '').lower()
raw_pair = str(descr.get('pair', '') or order.get('pair', '') or '').upper()
norm_pair = pair_aliases.get(raw_pair, raw_pair)
try:
vol = float(order.get('vol', 0) or 0)
vol_exec = float(order.get('vol_exec', 0) or 0)
remaining_vol = max(0.0, vol - vol_exec)
except Exception:
remaining_vol = 0.0
price_raw = descr.get('price', None)
if price_raw in (None, '', '0', 0):
price_raw = order.get('price', 0)
try:
limit_price = float(price_raw or 0)
except Exception:
limit_price = 0.0
normalized[txid] = {
'pair': norm_pair,
'side': side,
'remaining_vol': remaining_vol,
'limit_price': limit_price,
'raw': order,
}
return normalized
except Exception as e:
self.logger.debug(f"Could not load open-order snapshot: {e}")
return {}
def _has_open_order(self, pair, side) -> bool:
"""Return True when there is already a pending order for pair+side."""
try:
for _, meta in self._get_open_orders_snapshot().items():
if meta.get('pair') == pair and meta.get('side') == side and float(meta.get('remaining_vol', 0.0)) > 0:
return True
return False
except Exception:
return False
def _estimate_open_buy_reserve_eur(self) -> float:
"""Best-effort estimate of EUR currently reserved in open BUY orders.
Kraken's free EUR balance can drop as soon as a post-only BUY is placed,
even before the trade is filled and before crypto holdings appear.
Without adding this reserve back, the bot can misread a normal pending
entry as a large portfolio drawdown on a small account.
"""
try:
reserved_eur = 0.0
for _, meta in self._get_open_orders_snapshot().items():
if meta.get('side') != 'buy':
continue
remaining_vol = float(meta.get('remaining_vol', 0.0))
limit_price = float(meta.get('limit_price', 0.0))
if remaining_vol > 0 and limit_price > 0:
reserved_eur += remaining_vol * limit_price
return reserved_eur
except Exception as e:
self.logger.debug(f"Could not estimate reserved BUY EUR from open orders: {e}")
return 0.0
def _load_trade_history_from_nas(self, year: int) -> dict:
"""Load persisted trade history from NAS JSON file. Returns {} if unavailable."""
path = self.nas_root / str(year) / 'trade_history' / f'trades_{year}.json'
try:
if path.exists():
with open(path, 'r') as f:
data = json.load(f)
self.logger.info(f"Loaded {len(data)} trades from NAS cache ({path.name})")
return data
except Exception as e:
self.logger.warning(f"Could not load NAS trade history ({path}): {e}")
return {}
def _save_trade_history_to_nas(self, trades: dict, year: int) -> None:
"""Persist trade history to NAS JSON file for future incremental loads."""
try:
trade_history_dir = self.nas_root / str(year) / 'trade_history'
trade_history_dir.mkdir(parents=True, exist_ok=True)
path = trade_history_dir / f'trades_{year}.json'
with open(path, 'w') as f:
json.dump(trades, f, separators=(',', ':'))
self.logger.debug(f"Saved {len(trades)} trades to NAS cache ({path.name})")
except Exception as e:
self.logger.warning(f"Could not save trade history to NAS ({e}) — NAS mounted?")
def _refresh_trade_history_cache(self, force: bool = False) -> None:
"""Fetch trade history from Kraken API and merge into in-memory + NAS cache.
Uses TTL: only fetches if cache is older than _TRADE_HISTORY_REFRESH_INTERVAL seconds.
Always fetches after a trade (force=True).
Incremental: only requests trades newer than the last cached entry.
"""
now = time.time()
if not force and (now - self._trade_history_last_fetch) < _TRADE_HISTORY_REFRESH_INTERVAL:
return
year = datetime.now(tz=timezone.utc).year
year_start_ts = int(datetime(year, 1, 1, tzinfo=timezone.utc).timestamp())
# Bootstrap from NAS on first run (cache is empty)
if not self._trade_history_cache:
self._trade_history_cache = self._load_trade_history_from_nas(year)
# Only fetch trades newer than the latest entry we already have
if self._trade_history_cache:
last_ts = max(float(t.get('time', 0)) for t in self._trade_history_cache.values())
fetch_start = max(year_start_ts, int(last_ts))
else:
fetch_start = year_start_ts
new_trades = self.api_client.get_trade_history(start=fetch_start, fetch_all=True)
if new_trades:
self._trade_history_cache.update(new_trades)
self._save_trade_history_to_nas(self._trade_history_cache, year)
self._trade_history_last_fetch = now
self.logger.debug(
f"Trade history cache refreshed: {len(self._trade_history_cache)} total trades "
f"(+{len(new_trades) if new_trades else 0} new, start={fetch_start})"
)
def load_purchase_prices_from_history(self, force: bool = False):
"""Rebuild per-pair average entry price + realized PnL from Kraken trade history.
Logic:
- BUY increases position size and weighted average entry (including fees)
- SELL reduces position and realizes PnL (net of fees)
Uses an in-memory + NAS cache to avoid hitting the Kraken API on every loop iteration.
Pass force=True immediately after a trade to ensure fresh data.
"""
try:
self._refresh_trade_history_cache(force=force)
trades = self._trade_history_cache
if not trades:
return
watched = set(self.trade_pairs)
pair_aliases = {
'XXBTZEUR': 'XBTEUR', 'XBTEUR': 'XBTEUR',
'XETHZEUR': 'ETHEUR', 'ETHEUR': 'ETHEUR',
'SOLEUR': 'SOLEUR',
'ADAEUR': 'ADAEUR',
'DOTEUR': 'DOTEUR',
'XXRPZEUR': 'XRPEUR', 'XRPEUR': 'XRPEUR',
'LINKEUR': 'LINKEUR',
'MATICEUR': 'MATICEUR',
'POLEUR': 'POLEUR'
}
# Reset state before replay
for pair in watched:
self.position_qty[pair] = 0.0
self.purchase_prices[pair] = 0.0
self.realized_pnl[pair] = 0.0