-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalyzer.py
More file actions
866 lines (715 loc) · 30.1 KB
/
Copy pathanalyzer.py
File metadata and controls
866 lines (715 loc) · 30.1 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
"""Motor de análisis técnico multi-estrategia."""
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
import pandas as pd
import yfinance as yf
from ta.trend import EMAIndicator, MACD, SMAIndicator
from ta.momentum import RSIIndicator, StochasticOscillator
from ta.volatility import BollingerBands, AverageTrueRange
logger = logging.getLogger(__name__)
@dataclass
class Signal:
ticker: str
action: str # BUY, SELL, HOLD
score: float # -10 a +10
confidence: float # 0.0 a 1.0
strategy: str # Nombre de la estrategia que generó la señal
entry_price: float
target_price: float
stop_loss: float
reasons: list[str]
expected_pnl: float # EUR esperado después de comisiones
risk_reward: float # Ratio riesgo/beneficio
def fetch_data(ticker: str, period: str = "6mo", interval: str = "1d") -> Optional[pd.DataFrame]:
"""Descarga datos de Yahoo Finance."""
try:
stock = yf.Ticker(ticker)
df = stock.history(period=period, interval=interval)
if df.empty or len(df) < 50:
logger.warning(f"Datos insuficientes para {ticker}")
return None
return df
except Exception as e:
logger.error(f"Error descargando {ticker}: {e}")
return None
def add_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""Añade todos los indicadores técnicos al DataFrame."""
close = df["Close"]
high = df["High"]
low = df["Low"]
volume = df["Volume"]
# EMAs
df["ema_9"] = EMAIndicator(close, window=9).ema_indicator()
df["ema_21"] = EMAIndicator(close, window=21).ema_indicator()
df["ema_50"] = EMAIndicator(close, window=50).ema_indicator()
# SMA 200 para tendencia de largo plazo
if len(df) >= 200:
df["sma_200"] = SMAIndicator(close, window=200).sma_indicator()
else:
# Usar EMA 50 como proxy si no hay suficientes datos para SMA 200
df["sma_200"] = df["ema_50"]
# RSI
df["rsi"] = RSIIndicator(close, window=14).rsi()
# MACD
macd = MACD(close, window_slow=26, window_fast=12, window_sign=9)
df["macd"] = macd.macd()
df["macd_signal"] = macd.macd_signal()
df["macd_hist"] = macd.macd_diff()
# Bollinger Bands
bb = BollingerBands(close, window=20, window_dev=2)
df["bb_upper"] = bb.bollinger_hband()
df["bb_lower"] = bb.bollinger_lband()
df["bb_mid"] = bb.bollinger_mavg()
df["bb_width"] = (df["bb_upper"] - df["bb_lower"]) / df["bb_mid"]
# Stochastic
stoch = StochasticOscillator(high, low, close, window=14, smooth_window=3)
df["stoch_k"] = stoch.stoch()
df["stoch_d"] = stoch.stoch_signal()
# ATR para volatilidad
atr = AverageTrueRange(high, low, close, window=14)
df["atr"] = atr.average_true_range()
# Volumen medio
df["vol_avg_20"] = volume.rolling(window=20).mean()
df["vol_ratio"] = volume / df["vol_avg_20"]
return df
def strategy_ema_crossover(df: pd.DataFrame, ticker: str) -> Optional[Signal]:
"""Estrategia de cruce de EMAs con confirmación de volumen."""
last = df.iloc[-1]
prev = df.iloc[-2]
price = last["Close"]
score = 0.0
reasons = []
# Cruce EMA 9/21
if prev["ema_9"] <= prev["ema_21"] and last["ema_9"] > last["ema_21"]:
score += 3.0
reasons.append("Cruce alcista EMA 9/21")
elif prev["ema_9"] >= prev["ema_21"] and last["ema_9"] < last["ema_21"]:
score -= 3.0
reasons.append("Cruce bajista EMA 9/21")
# Posición respecto a EMA 50
if price > last["ema_50"]:
score += 1.5
reasons.append("Precio por encima de EMA 50")
else:
score -= 1.5
reasons.append("Precio por debajo de EMA 50")
# Tendencia de largo plazo (SMA 200)
if price > last["sma_200"]:
score += 1.0
reasons.append("Tendencia alcista (sobre SMA 200)")
else:
score -= 1.0
# Confirmación de volumen
if last["vol_ratio"] > 1.3:
score *= 1.2
reasons.append(f"Volumen alto ({last['vol_ratio']:.1f}x promedio)")
# RSI como filtro
if last["rsi"] > 75:
score -= 1.5
reasons.append(f"RSI sobrecomprado ({last['rsi']:.0f})")
elif last["rsi"] < 25:
score += 1.5
reasons.append(f"RSI sobrevendido ({last['rsi']:.0f})")
if abs(score) < 2.0:
return None
action = "BUY" if score > 0 else "SELL"
atr = last["atr"]
multiplier = min(abs(score) / 3, 3.0)
if action == "BUY":
target = price + (atr * multiplier)
stop = price - (atr * 1.5)
else:
target = price - (atr * multiplier)
stop = price + (atr * 1.5)
risk = abs(price - stop)
reward = abs(target - price)
rr = reward / risk if risk > 0 else 0
return Signal(
ticker=ticker, action=action, score=score,
confidence=min(abs(score) / 10, 1.0),
strategy="ema_crossover",
entry_price=price, target_price=target, stop_loss=stop,
reasons=reasons, expected_pnl=0, risk_reward=rr
)
def strategy_rsi_macd(df: pd.DataFrame, ticker: str) -> Optional[Signal]:
"""Estrategia combinada RSI + MACD."""
last = df.iloc[-1]
prev = df.iloc[-2]
price = last["Close"]
score = 0.0
reasons = []
# RSI
rsi = last["rsi"]
if rsi < 30:
score += 2.5
reasons.append(f"RSI sobrevendido ({rsi:.0f})")
elif rsi < 40:
score += 1.0
reasons.append(f"RSI bajo ({rsi:.0f})")
elif rsi > 70:
score -= 2.5
reasons.append(f"RSI sobrecomprado ({rsi:.0f})")
elif rsi > 60:
score -= 1.0
# MACD crossover
if prev["macd"] <= prev["macd_signal"] and last["macd"] > last["macd_signal"]:
score += 2.5
reasons.append("Cruce alcista MACD")
elif prev["macd"] >= prev["macd_signal"] and last["macd"] < last["macd_signal"]:
score -= 2.5
reasons.append("Cruce bajista MACD")
# MACD histograma creciente
if last["macd_hist"] > prev["macd_hist"] and last["macd_hist"] > 0:
score += 1.0
reasons.append("Momentum MACD creciente")
elif last["macd_hist"] < prev["macd_hist"] and last["macd_hist"] < 0:
score -= 1.0
reasons.append("Momentum MACD decreciente")
# Stochastic
if last["stoch_k"] < 20 and last["stoch_k"] > last["stoch_d"]:
score += 1.5
reasons.append("Stochastic sobrevendido con cruce alcista")
elif last["stoch_k"] > 80 and last["stoch_k"] < last["stoch_d"]:
score -= 1.5
reasons.append("Stochastic sobrecomprado con cruce bajista")
# Volumen
if last["vol_ratio"] > 1.5:
score *= 1.15
reasons.append(f"Volumen alto ({last['vol_ratio']:.1f}x)")
if abs(score) < 2.0:
return None
action = "BUY" if score > 0 else "SELL"
atr = last["atr"]
if action == "BUY":
target = price + (atr * 2.0)
stop = price - (atr * 1.5)
else:
target = price - (atr * 2.0)
stop = price + (atr * 1.5)
risk = abs(price - stop)
reward = abs(target - price)
rr = reward / risk if risk > 0 else 0
return Signal(
ticker=ticker, action=action, score=score,
confidence=min(abs(score) / 10, 1.0),
strategy="rsi_macd",
entry_price=price, target_price=target, stop_loss=stop,
reasons=reasons, expected_pnl=0, risk_reward=rr
)
def strategy_mean_reversion(df: pd.DataFrame, ticker: str) -> Optional[Signal]:
"""Estrategia de reversión a la media usando Bollinger Bands."""
last = df.iloc[-1]
prev = df.iloc[-2]
price = last["Close"]
score = 0.0
reasons = []
# Posición en las Bollinger Bands
bb_width = last["bb_upper"] - last["bb_lower"]
if bb_width == 0:
return None # Sin volatilidad, no hay señal
bb_position = (price - last["bb_lower"]) / bb_width
if bb_position < 0.05:
score += 3.0
reasons.append("Precio en banda inferior de Bollinger")
elif bb_position < 0.2:
score += 1.5
reasons.append("Precio cerca de banda inferior")
elif bb_position > 0.95:
score -= 3.0
reasons.append("Precio en banda superior de Bollinger")
elif bb_position > 0.8:
score -= 1.5
reasons.append("Precio cerca de banda superior")
# Confirmación RSI
if last["rsi"] < 30 and score > 0:
score += 2.0
reasons.append(f"RSI confirma sobreventa ({last['rsi']:.0f})")
elif last["rsi"] > 70 and score < 0:
score += -2.0 # Más negativo
reasons.append(f"RSI confirma sobrecompra ({last['rsi']:.0f})")
# Ancho de banda (volatilidad)
if last["bb_width"] > df["bb_width"].rolling(50).mean().iloc[-1] * 1.5:
score *= 0.7 # Reducir confianza en alta volatilidad
reasons.append("Alta volatilidad - señal reducida")
# Volumen en la reversión
if last["vol_ratio"] > 1.2 and abs(score) > 2:
score *= 1.1
reasons.append("Volumen apoya la reversión")
if abs(score) < 2.5:
return None
action = "BUY" if score > 0 else "SELL"
# Target: media de Bollinger
target = last["bb_mid"]
atr = last["atr"]
if action == "BUY":
stop = price - (atr * 1.5)
else:
stop = price + (atr * 1.5)
risk = abs(price - stop)
reward = abs(target - price)
rr = reward / risk if risk > 0 else 0
return Signal(
ticker=ticker, action=action, score=score,
confidence=min(abs(score) / 10, 1.0),
strategy="mean_reversion",
entry_price=price, target_price=target, stop_loss=stop,
reasons=reasons, expected_pnl=0, risk_reward=rr
)
def strategy_momentum_breakout(df: pd.DataFrame, ticker: str) -> Optional[Signal]:
"""Estrategia de momentum/breakout: compra rupturas de máximos de 20 días con confirmación de tendencia."""
last = df.iloc[-1]
prev = df.iloc[-2]
price = last["Close"]
# Filtro obligatorio: precio debe estar por encima de EMA 50
if price < last["ema_50"]:
return None
score = 0.0
reasons = []
# 1. Breakout de máximo de 20 días con volumen
high_20 = df["Close"].iloc[-20:].max()
is_breakout = price >= high_20 and last["vol_ratio"] > 1.2
if not is_breakout:
return None
score += 3.0
reasons.append(f"Ruptura de máximo de 20 días ({high_20:.2f})")
# 2. Confirmación de tendencia: por encima de EMA 50
score += 1.5
reasons.append("Precio por encima de EMA 50 (tendencia alcista)")
# 3. RSI entre 50-75 (fuerte pero no sobrecomprado)
rsi = last["rsi"]
if 50 <= rsi <= 65:
score += 1.5
reasons.append(f"RSI en zona óptima ({rsi:.0f})")
elif 65 < rsi <= 75:
score += 1.0
reasons.append(f"RSI fuerte ({rsi:.0f})")
else:
# RSI fuera de rango 50-75: no confirma momentum
return None
# 4. MACD histograma positivo y creciente
if last["macd_hist"] > 0 and last["macd_hist"] > prev["macd_hist"]:
score += 1.5
reasons.append("MACD histograma positivo y creciente")
# 5. Bonus por volumen excepcional
if last["vol_ratio"] > 1.5:
score += 1.0
reasons.append(f"Volumen alto ({last['vol_ratio']:.1f}x promedio)")
if last["vol_ratio"] > 2.0:
score += 0.5
reasons.append(f"Volumen excepcional ({last['vol_ratio']:.1f}x promedio)")
# Filtro de score mínimo
if score < 3.0:
return None
# ATR-based targets (amplios para dejar correr los ganadores)
atr = last["atr"]
stop = price - (atr * 2.5)
target = price + (atr * 4.0)
risk = abs(price - stop)
reward = abs(target - price)
rr = reward / risk if risk > 0 else 0
return Signal(
ticker=ticker, action="BUY", score=score,
confidence=min(abs(score) / 10, 1.0),
strategy="momentum_breakout",
entry_price=price, target_price=target, stop_loss=stop,
reasons=reasons, expected_pnl=0, risk_reward=rr
)
def get_multiframe_context(ticker: str) -> dict:
"""Obtiene contexto de timeframe superior (semanal) para confirmar tendencia."""
try:
df_w = fetch_data(ticker, period="1y", interval="1wk")
if df_w is None or len(df_w) < 21:
return {"trend": "LATERAL", "weekly_rsi": 50.0, "alignment_score": 0}
close = df_w["Close"]
ema_9 = EMAIndicator(close, window=9).ema_indicator()
ema_21 = EMAIndicator(close, window=21).ema_indicator()
rsi = RSIIndicator(close, window=14).rsi()
last_ema9 = ema_9.iloc[-1]
last_ema21 = ema_21.iloc[-1]
last_price = close.iloc[-1]
last_rsi = rsi.iloc[-1]
# Determinar tendencia semanal
alignment_score = 0
if last_ema9 > last_ema21:
alignment_score += 1
else:
alignment_score -= 1
if last_price > last_ema9:
alignment_score += 1
elif last_price < last_ema21:
alignment_score -= 1
if alignment_score >= 1:
trend = "ALCISTA"
elif alignment_score <= -1:
trend = "BAJISTA"
else:
trend = "LATERAL"
return {
"trend": trend,
"weekly_rsi": last_rsi,
"alignment_score": alignment_score, # -2 a +2
}
except Exception as e:
logger.error(f"Error obteniendo contexto multiframe para {ticker}: {e}")
return {"trend": "LATERAL", "weekly_rsi": 50.0, "alignment_score": 0}
def get_volume_confirmation(df: pd.DataFrame) -> dict:
"""Analiza el volumen para confirmar o rechazar señales."""
try:
volume = df["Volume"]
close = df["Close"]
reasons = []
score_mod = 0.0
# 1. Tendencia de volumen (últimos 5 días)
recent_vol = volume.iloc[-5:]
vol_slope = (recent_vol.iloc[-1] - recent_vol.iloc[0]) / recent_vol.iloc[0] if recent_vol.iloc[0] > 0 else 0
if vol_slope > 0.1:
score_mod += 0.5
reasons.append("Volumen creciente últimos 5 días")
elif vol_slope < -0.2:
score_mod -= 0.5
reasons.append("Volumen decreciente últimos 5 días")
# 2. Spike de volumen (>2x promedio 20 días)
vol_avg = volume.rolling(20).mean().iloc[-1]
last_vol = volume.iloc[-1]
if vol_avg > 0 and last_vol > vol_avg * 2:
score_mod += 0.5
reasons.append(f"Spike de volumen ({last_vol / vol_avg:.1f}x promedio)")
# 3. OBV (On-Balance Volume) trend
obv = pd.Series(0.0, index=df.index)
for i in range(1, len(df)):
if close.iloc[i] > close.iloc[i - 1]:
obv.iloc[i] = obv.iloc[i - 1] + volume.iloc[i]
elif close.iloc[i] < close.iloc[i - 1]:
obv.iloc[i] = obv.iloc[i - 1] - volume.iloc[i]
else:
obv.iloc[i] = obv.iloc[i - 1]
obv_ema = obv.ewm(span=10).mean()
if obv.iloc[-1] > obv_ema.iloc[-1]:
score_mod += 0.5
reasons.append("OBV por encima de su media (presión compradora)")
else:
score_mod -= 0.5
reasons.append("OBV por debajo de su media (presión vendedora)")
# Limitar rango
score_mod = max(-1.5, min(1.5, score_mod))
return {"score_modifier": score_mod, "reasons": reasons}
except Exception as e:
logger.error(f"Error en análisis de volumen: {e}")
return {"score_modifier": 0.0, "reasons": []}
def detect_market_regime(df: pd.DataFrame) -> dict:
"""Detecta si el mercado está en tendencia o en rango."""
try:
high = df["High"]
low = df["Low"]
close = df["Close"]
last = df.iloc[-1]
# --- ADX calculation (14-period) ---
plus_dm_values = []
minus_dm_values = []
for i in range(len(df)):
if i == 0:
plus_dm_values.append(0.0)
minus_dm_values.append(0.0)
continue
up = high.iloc[i] - high.iloc[i - 1]
down = low.iloc[i - 1] - low.iloc[i]
plus_dm_values.append(up if (up > down and up > 0) else 0.0)
minus_dm_values.append(down if (down > up and down > 0) else 0.0)
plus_dm_s = pd.Series(plus_dm_values, index=df.index)
minus_dm_s = pd.Series(minus_dm_values, index=df.index)
atr_14 = df["atr"] # Already calculated ATR(14)
# Smoothed DI+ and DI-
plus_di = 100 * (plus_dm_s.ewm(alpha=1 / 14, min_periods=14).mean() / atr_14)
minus_di = 100 * (minus_dm_s.ewm(alpha=1 / 14, min_periods=14).mean() / atr_14)
# DX and ADX
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, 1)
adx = dx.ewm(alpha=1 / 14, min_periods=14).mean()
adx_value = adx.iloc[-1] if not pd.isna(adx.iloc[-1]) else 20.0
# --- EMA alignment ---
ema9 = last["ema_9"]
ema21 = last["ema_21"]
ema50 = last["ema_50"]
ema_aligned = (ema9 > ema21 > ema50) or (ema9 < ema21 < ema50)
# --- Bollinger Band width vs 50-period average ---
bb_width_avg_50 = df["bb_width"].rolling(50).mean().iloc[-1]
bb_narrow = last["bb_width"] < bb_width_avg_50 if not pd.isna(bb_width_avg_50) else False
# --- Determine regime ---
trending_signals = 0
ranging_signals = 0
if adx_value > 25:
trending_signals += 1
elif adx_value < 20:
ranging_signals += 1
if ema_aligned:
trending_signals += 1
else:
ranging_signals += 1
if bb_narrow:
ranging_signals += 1 # Narrow bands = consolidation
else:
trending_signals += 1
if trending_signals >= 2 and ranging_signals <= 1:
regime = "TRENDING"
elif ranging_signals >= 2 and trending_signals <= 1:
regime = "RANGING"
else:
regime = "MIXED"
# Trend strength: normalize ADX to 0-1 range (ADX 10-50 mapped to 0-1)
trend_strength = max(0.0, min(1.0, (adx_value - 10) / 40))
return {
"regime": regime,
"adx": round(adx_value, 2),
"trend_strength": round(trend_strength, 2),
}
except Exception as e:
logger.error(f"Error detectando régimen de mercado: {e}")
return {"regime": "MIXED", "adx": 20.0, "trend_strength": 0.25}
def get_market_direction() -> dict:
"""Obtiene la dirección general del mercado (SPY)."""
try:
df_spy = fetch_data("SPY", period="3mo", interval="1d")
if df_spy is None or len(df_spy) < 21:
return {"direction": "NEUTRAL", "spy_above_ema21": True, "spy_rsi": 50.0}
close = df_spy["Close"]
ema_21 = EMAIndicator(close, window=21).ema_indicator()
rsi = RSIIndicator(close, window=14).rsi()
last_price = close.iloc[-1]
last_ema21 = ema_21.iloc[-1]
last_rsi = rsi.iloc[-1]
spy_above_ema21 = bool(last_price > last_ema21)
if spy_above_ema21 and last_rsi > 50:
direction = "BULLISH"
elif not spy_above_ema21 and last_rsi < 50:
direction = "BEARISH"
else:
direction = "NEUTRAL"
return {
"direction": direction,
"spy_above_ema21": spy_above_ema21,
"spy_rsi": round(float(last_rsi), 2),
}
except Exception as e:
logger.error(f"Error obteniendo dirección del mercado (SPY): {e}")
return {"direction": "NEUTRAL", "spy_above_ema21": True, "spy_rsi": 50.0}
def analyze_ticker(ticker: str, capital: float, max_position_pct: float = 0.10) -> list[Signal]:
"""Analiza un ticker con todas las estrategias + sentimiento + calendario."""
df = fetch_data(ticker)
if df is None:
return []
df = add_indicators(df)
price = df.iloc[-1]["Close"]
# Obtener contexto externo (sentimiento + calendario)
from sentiment import calculate_sentiment_modifier
from calendar_eco import check_ticker_calendar_risk
sent_data = calculate_sentiment_modifier(ticker)
cal_data = check_ticker_calendar_risk(ticker)
# Contexto multiframe y confirmación de volumen
mf_ctx = get_multiframe_context(ticker)
vol_conf = get_volume_confirmation(df)
# Régimen de mercado y dirección SPY
regime = detect_market_regime(df)
market_dir = get_market_direction()
# Seleccionar estrategias según régimen de mercado
# Momentum siempre activa, las clásicas solo donde el régimen les favorece
strategies = [strategy_momentum_breakout] # Siempre activa
if regime["regime"] != "TRENDING":
# EMA crossover y mean_reversion solo en mercado lateral/mixto
strategies.append(strategy_mean_reversion)
if regime["regime"] != "RANGING":
# EMA crossover funciona en tendencia, no en rango
strategies.append(strategy_ema_crossover)
# RSI+MACD funciona en ambos regímenes
strategies.append(strategy_rsi_macd)
signals = []
for strategy_fn in strategies:
try:
signal = strategy_fn(df, ticker)
if signal and abs(signal.score) >= 2.0:
# Aplicar modificadores de sentimiento y calendario
original_score = signal.score
if sent_data["modifier"] != 0:
signal.score += sent_data["modifier"]
if sent_data["details"]:
signal.reasons.append(
f"Sentimiento noticias: {sent_data['details']['label']} "
f"({sent_data['details']['sentiment']:.2f})"
)
if cal_data["modifier"] != 0:
signal.score += cal_data["modifier"]
if cal_data["events"]:
events_str = ", ".join(e["event"] for e in cal_data["events"][:2])
signal.reasons.append(f"Calendario: {events_str}")
# Aplicar contexto multiframe
is_buy = signal.score > 0
weekly_trend = mf_ctx["trend"]
alignment = mf_ctx["alignment_score"]
if is_buy and weekly_trend == "BAJISTA":
# Señal de compra contra tendencia semanal bajista
penalty = min(3.0, abs(alignment) * 1.5)
signal.score -= penalty
signal.reasons.append(
f"Tendencia semanal BAJISTA (penalización -{penalty:.1f})"
)
elif not is_buy and weekly_trend == "ALCISTA":
# Señal de venta contra tendencia semanal alcista
penalty = min(3.0, abs(alignment) * 1.5)
signal.score += penalty # Reduce el score negativo
signal.reasons.append(
f"Tendencia semanal ALCISTA (penalización +{penalty:.1f})"
)
elif is_buy and weekly_trend == "ALCISTA":
boost = min(2.0, abs(alignment) * 1.0)
signal.score += boost
signal.reasons.append(
f"Alineado con tendencia semanal ALCISTA (+{boost:.1f})"
)
elif not is_buy and weekly_trend == "BAJISTA":
boost = min(2.0, abs(alignment) * 1.0)
signal.score -= boost # Refuerza el score negativo
signal.reasons.append(
f"Alineado con tendencia semanal BAJISTA (-{boost:.1f})"
)
if mf_ctx["weekly_rsi"] > 75:
signal.reasons.append(f"RSI semanal sobrecomprado ({mf_ctx['weekly_rsi']:.0f})")
elif mf_ctx["weekly_rsi"] < 25:
signal.reasons.append(f"RSI semanal sobrevendido ({mf_ctx['weekly_rsi']:.0f})")
# Aplicar confirmación de volumen
if vol_conf["score_modifier"] != 0:
if is_buy:
signal.score += vol_conf["score_modifier"]
else:
signal.score -= vol_conf["score_modifier"]
signal.reasons.extend(vol_conf["reasons"])
# Aplicar régimen de mercado
if regime["regime"] == "RANGING" and signal.strategy == "ema_crossover":
signal.score -= 1.0 if signal.score > 0 else -1.0
signal.reasons.append(
f"Régimen RANGING penaliza ema_crossover (ADX={regime['adx']:.1f})"
)
elif regime["regime"] == "TRENDING" and signal.strategy == "mean_reversion":
signal.score -= 1.0 if signal.score > 0 else -1.0
signal.reasons.append(
f"Régimen TRENDING penaliza mean_reversion (ADX={regime['adx']:.1f})"
)
signal.reasons.append(
f"Régimen: {regime['regime']} (ADX={regime['adx']:.1f}, "
f"fuerza={regime['trend_strength']:.2f})"
)
# Aplicar filtro Kronos (confirmación con foundation model)
current_action = "BUY" if signal.score > 0 else "SELL"
try:
from kronos_filter import apply_kronos_filter
kronos_score, kronos_reasons = apply_kronos_filter(
signal.score, current_action, ticker, df
)
signal.score = kronos_score
signal.reasons.extend(kronos_reasons)
except ImportError:
pass
# Aplicar filtro de dirección de mercado (SPY)
current_action = "BUY" if signal.score > 0 else "SELL"
if market_dir["direction"] == "BEARISH" and current_action == "BUY":
signal.score -= 1.0
signal.reasons.append(
f"Mercado BEARISH penaliza BUY (SPY RSI={market_dir['spy_rsi']:.0f}, "
f"{'sobre' if market_dir['spy_above_ema21'] else 'bajo'} EMA21)"
)
elif market_dir["direction"] == "BULLISH" and current_action == "SELL":
signal.score += 1.0
signal.reasons.append(
f"Mercado BULLISH penaliza SELL (SPY RSI={market_dir['spy_rsi']:.0f}, "
f"{'sobre' if market_dir['spy_above_ema21'] else 'bajo'} EMA21)"
)
# Bloquear si sentimiento dice que no
if sent_data.get("block"):
signal.reasons.append(f"BLOQUEADO: {sent_data['warning']}")
continue
# Advertencias
if sent_data.get("warning"):
signal.reasons.append(sent_data["warning"])
if cal_data.get("warning"):
signal.reasons.append(cal_data["warning"])
# Recalcular confianza
signal.confidence = min(abs(signal.score) / 10, 1.0)
# Calcular PnL esperado con sizing dinámico
from config import (
POSITION_TIER_HIGH_SCORE, POSITION_TIER_HIGH_RR,
POSITION_PCT_HIGH, POSITION_PCT_NORMAL
)
sig_score = abs(signal.score)
if sig_score >= POSITION_TIER_HIGH_SCORE and signal.risk_reward >= POSITION_TIER_HIGH_RR:
effective_pct = POSITION_PCT_HIGH
else:
effective_pct = POSITION_PCT_NORMAL
max_investment = capital * effective_pct
shares = int(max_investment / price)
if shares < 1:
continue
from config import COMMISSION_PER_TRADE
commission = COMMISSION_PER_TRADE * 2 # compra + venta
expected_gain = abs(signal.target_price - price) * shares
signal.expected_pnl = expected_gain - commission
signals.append(signal)
except Exception as e:
logger.error(f"Error en estrategia {strategy_fn.__name__} para {ticker}: {e}")
# Ordenar por score absoluto descendente
signals.sort(key=lambda s: abs(s.score), reverse=True)
return signals
def get_best_signal(ticker: str, capital: float) -> Optional[Signal]:
"""Devuelve la mejor señal para un ticker, o None si no hay señales fuertes."""
from config import MIN_SIGNAL_SCORE, MIN_PROFIT_AFTER_COMMISSIONS, MIN_RISK_REWARD
signals = analyze_ticker(ticker, capital)
if not signals:
return None
best = signals[0]
# Filtros de calidad
if abs(best.score) < MIN_SIGNAL_SCORE:
return None
if best.expected_pnl < MIN_PROFIT_AFTER_COMMISSIONS:
return None
if best.risk_reward < MIN_RISK_REWARD:
return None
return best
def quick_analysis(ticker: str) -> dict:
"""Análisis rápido con sentimiento y calendario."""
df = fetch_data(ticker)
if df is None:
return {"error": f"No se pudieron obtener datos para {ticker}"}
df = add_indicators(df)
last = df.iloc[-1]
result = {
"ticker": ticker,
"price": last["Close"],
"rsi": last["rsi"],
"macd": last["macd"],
"macd_signal": last["macd_signal"],
"ema_9": last["ema_9"],
"ema_21": last["ema_21"],
"ema_50": last["ema_50"],
"bb_upper": last["bb_upper"],
"bb_lower": last["bb_lower"],
"bb_mid": last["bb_mid"],
"stoch_k": last["stoch_k"],
"stoch_d": last["stoch_d"],
"atr": last["atr"],
"vol_ratio": last["vol_ratio"],
"trend": "ALCISTA" if last["ema_9"] > last["ema_21"] > last["ema_50"] else
"BAJISTA" if last["ema_9"] < last["ema_21"] < last["ema_50"] else
"LATERAL",
}
# Añadir sentimiento de noticias
from sentiment import get_news_sentiment
from calendar_eco import check_ticker_calendar_risk
news = get_news_sentiment(ticker)
if news:
result["sentiment"] = news["label"]
result["sentiment_score"] = news["sentiment"]
result["high_impact_news"] = news["high_impact"]
result["top_headlines"] = [d["title"] for d in news.get("details", [])[:3]]
cal = check_ticker_calendar_risk(ticker)
if cal["events"]:
result["calendar_risk"] = cal["risk_level"]
result["calendar_events"] = [e["event"] for e in cal["events"][:3]]
return result