Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions src/financial_agent/broker/alpaca_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,8 @@ def submit_order(self, order: TradeOrder, dry_run: bool = True) -> dict[str, Any
log.info("dry_run_order", order=order.model_dump())
return {"status": "dry_run", "order": order.model_dump()}

# Detect crypto: check asset_class or symbol pattern (e.g. SOLUSD, BTC/USD)
is_crypto = (
order.asset_class == AssetClass.CRYPTO
or "/" in order.symbol
or order.symbol.endswith("USD")
)
# Detect crypto: check asset_class or "/" in symbol (e.g. BTC/USD)
is_crypto = order.asset_class == AssetClass.CRYPTO or "/" in order.symbol
tif = TimeInForce.GTC if is_crypto else TimeInForce.DAY
side = OrderSide.BUY if order.side == "buy" else OrderSide.SELL

Expand Down
2 changes: 2 additions & 0 deletions src/financial_agent/data/crypto_market.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ def _load_cache(self) -> CryptoMarketContext | None:

raw = json.loads(cache_path.read_text())
cached_time = datetime.fromisoformat(raw["timestamp"])
if cached_time.tzinfo is None:
cached_time = cached_time.replace(tzinfo=UTC)
age_hours = (datetime.now(tz=UTC) - cached_time).total_seconds() / 3600

if age_hours > _CACHE_MAX_AGE_HOURS:
Expand Down
13 changes: 11 additions & 2 deletions src/financial_agent/data/earnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ def _load_cache(self, symbols: list[str]) -> list[EarningsEvent]:

raw = json.loads(cache_path.read_text())
cached_time = datetime.fromisoformat(raw["timestamp"])
if cached_time.tzinfo is None:
cached_time = cached_time.replace(tzinfo=UTC)
age_hours = (datetime.now(tz=UTC) - cached_time).total_seconds() / 3600

if age_hours > _CACHE_MAX_AGE_HOURS:
Expand Down Expand Up @@ -122,8 +124,13 @@ def _fetch_calendar(self, symbols: list[str]) -> list[EarningsEvent]:
req = urllib.request.Request(url) # noqa: S310
req.add_header("User-Agent", "FinancialAgent/1.0")

with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
body = json.loads(resp.read().decode())
try:
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
body = json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
error_body = e.read().decode() if e.fp else ""
log.warning("earnings_http_error", status=e.code, error=error_body[:200])
raise

# Handle dict response (FMP stable API may return a single object)
if isinstance(body, dict):
Expand Down Expand Up @@ -151,6 +158,8 @@ def _fetch_calendar(self, symbols: list[str]) -> list[EarningsEvent]:
continue

days_until = (earnings_date - today).days
if days_until < 0:
continue

events.append(
EarningsEvent(
Expand Down
2 changes: 2 additions & 0 deletions src/financial_agent/data/fundamentals.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ def _load_cache(self, symbols: list[str]) -> dict[str, FundamentalData]:

raw = json.loads(cache_path.read_text())
cached_time = datetime.fromisoformat(raw["timestamp"])
if cached_time.tzinfo is None:
cached_time = cached_time.replace(tzinfo=UTC)
age_hours = (datetime.now(tz=UTC) - cached_time).total_seconds() / 3600

if age_hours > _CACHE_MAX_AGE_HOURS:
Expand Down
46 changes: 39 additions & 7 deletions src/financial_agent/screener_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,26 @@ def main() -> None:
# Add relative strength
technicals = technical.compute_relative_strength(technicals, "SPY")

# Fetch VIX for adaptive thresholds
vix_level: float | None = None
try:
from financial_agent.data.macro import MacroProvider

macro = MacroProvider().fetch()
vix_level = macro.vix_level
except Exception:
log.debug("screener_vix_fetch_failed", exc_info=True)

# Adaptive thresholds based on market volatility
if vix_level is not None and vix_level < 15:
vol_thresh, move_thresh, rs_thresh = 3.0, 5.0, 85
elif vix_level is not None and vix_level > 25:
vol_thresh, move_thresh, rs_thresh = 1.5, 2.0, 70
else:
vol_thresh, move_thresh, rs_thresh = 2.0, 3.0, 80

log.info("screener_thresholds", vix=vix_level, vol=vol_thresh, move=move_thresh, rs=rs_thresh)

# Screen for actionable setups
alerts: list[dict[str, str]] = []

Expand All @@ -87,14 +107,14 @@ def main() -> None:

reasons: list[str] = []

# Unusual volume (2x+ average)
# Unusual volume
rel_vol = ind.get("relative_volume", 0)
if rel_vol >= 2.0:
if rel_vol >= vol_thresh:
reasons.append(f"unusual volume ({rel_vol:.1f}x avg)")

# Big daily move (3%+)
# Big daily move
daily_ret = abs(ind.get("daily_return_pct", 0))
if daily_ret >= 3.0:
if daily_ret >= move_thresh:
direction = "up" if ind.get("daily_return_pct", 0) > 0 else "down"
reasons.append(f"big move {direction} ({daily_ret:.1f}%)")

Expand All @@ -103,9 +123,9 @@ def main() -> None:
if pct_from_high > -3.0:
reasons.append(f"near 52w high ({pct_from_high:+.1f}%)")

# Strong relative strength (top 20%)
# Strong relative strength
rs_rank = ind.get("rs_rank_pct", 0)
if rs_rank >= 80:
if rs_rank >= rs_thresh:
reasons.append(f"strong RS (top {100 - rs_rank:.0f}%)")

# Price above 200-day SMA with positive MACD
Expand Down Expand Up @@ -172,7 +192,19 @@ def main() -> None:
]
_run_gh_command(cmd)

_write_github_output({"alerts": len(top_alerts)})
# Auto-add top 3 screener picks to watchlist for immediate trading
top_picks = [a["symbol"] for a in top_alerts[:3] if a["symbol"] not in current_watchlist]
if top_picks:
expanded = ",".join(sorted(current_watchlist | set(top_picks)))
try:
cmd_var = ["gh", "variable", "set", "TRADING_WATCHLIST", "--body", expanded]
ok, _ = _run_gh_command(cmd_var)
if ok:
log.info("screener_watchlist_expanded", added=top_picks, new_watchlist=expanded)
except Exception:
log.warning("screener_watchlist_update_failed", exc_info=True)

_write_github_output({"alerts": len(top_alerts), "auto_added": top_picks})
log.info("screener_complete")


Expand Down
8 changes: 4 additions & 4 deletions src/financial_agent/strategy/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,15 +246,15 @@ def _size_buy_order(

# Get current price: existing position > technicals > skip
current_pos = portfolio.get_position(signal.symbol)
if current_pos:
if current_pos and current_pos.current_price > 0:
est_price = current_pos.current_price
elif signal.symbol in technicals and "current_price" in technicals[signal.symbol]:
elif signal.symbol in technicals and technicals[signal.symbol].get("current_price", 0) > 0:
est_price = technicals[signal.symbol]["current_price"]
else:
log.warning("skip_buy_no_price", symbol=signal.symbol)
return None

qty = round(target_value / est_price, 2) if est_price > 0 else 0
qty = round(target_value / est_price, 2)

if qty <= 0:
return None
Expand Down Expand Up @@ -317,7 +317,7 @@ def _size_sell_order(
# Limit order support for sells
order_type = OrderType.MARKET
limit_price: float | None = None
if self._data_config and self._data_config.use_limit_orders:
if self._data_config and self._data_config.use_limit_orders and position.current_price > 0:
slippage = self._data_config.slippage_tolerance_pct
order_type = OrderType.LIMIT
limit_price = round(position.current_price * (1 - slippage), 2)
Expand Down
41 changes: 33 additions & 8 deletions src/financial_agent/strategy/technical.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

from __future__ import annotations

import math
from typing import TYPE_CHECKING

import structlog
import ta # type: ignore[import-untyped]

if TYPE_CHECKING:
import pandas as pd

log = structlog.get_logger()


class TechnicalAnalyzer:
"""Compute technical indicators for a set of symbols."""
Expand All @@ -25,7 +29,8 @@ def compute_indicators(self, bars: pd.DataFrame) -> dict[str, dict[str, float]]:
try:
df = bars.loc[symbol].copy()
results[symbol] = self._indicators_for_symbol(df)
except Exception: # noqa: S112
except (KeyError, ValueError, IndexError) as e:
log.warning("indicator_calc_failed", symbol=symbol, error=str(e))
continue

return results
Expand Down Expand Up @@ -93,23 +98,39 @@ def _indicators_for_symbol(self, df: pd.DataFrame) -> dict[str, float]:

# Weekly trend proxy from daily data (Issue #22)
if len(close) >= 60:
weekly_close = close.iloc[::5] # Sample every 5 days
indicators["weekly_sma_10"] = weekly_close.rolling(window=10).mean().iloc[-1]
indicators["weekly_trend"] = (
1.0 if close.iloc[-1] > indicators.get("weekly_sma_10", 0) else -1.0
)
weekly_close = close.iloc[-60:].iloc[::5] # Sample last 60 bars, every 5th
weekly_mean = weekly_close.rolling(window=10).mean().iloc[-1]
if not math.isnan(weekly_mean):
indicators["weekly_sma_10"] = weekly_mean
indicators["weekly_trend"] = 1.0 if close.iloc[-1] > weekly_mean else -1.0

# Momentum indicators
indicators["rsi_14"] = ta.momentum.rsi(close, window=14).iloc[-1]
stoch = ta.momentum.StochasticOscillator(high, low, close)
indicators["stoch_k"] = stoch.stoch().iloc[-1]
indicators["stoch_d"] = stoch.stoch_signal().iloc[-1]

# ADX for trend strength (critical for momentum confirmation)
if len(close) >= 14:
adx_val = ta.trend.adx(high, low, close, window=14).iloc[-1]
if not math.isnan(adx_val):
indicators["adx_14"] = adx_val

# Rate of Change for momentum velocity
if len(close) >= 12:
roc_val = ta.momentum.roc(close, window=12).iloc[-1]
if not math.isnan(roc_val):
indicators["roc_12"] = roc_val

# Volatility indicators
bb = ta.volatility.BollingerBands(close)
indicators["bb_upper"] = bb.bollinger_hband().iloc[-1]
indicators["bb_lower"] = bb.bollinger_lband().iloc[-1]
indicators["bb_width"] = bb.bollinger_wband().iloc[-1]
# Normalized BB width for cross-symbol comparison
bb_mid = (indicators["bb_upper"] + indicators["bb_lower"]) / 2
if bb_mid > 0:
indicators["bb_width_pct"] = (indicators["bb_width"] / bb_mid) * 100
indicators["atr_14"] = ta.volatility.average_true_range(high, low, close).iloc[-1]

# ATR as % of price (Issue #28: volatility-aware sizing)
Expand All @@ -130,7 +151,10 @@ def _indicators_for_symbol(self, df: pd.DataFrame) -> dict[str, float]:
# Current price context
indicators["current_price"] = current_price
indicators["price_vs_sma20"] = (close.iloc[-1] / indicators["sma_20"] - 1) * 100
indicators["daily_return_pct"] = ((close.iloc[-1] / close.iloc[-2]) - 1) * 100
if len(close) >= 2:
indicators["daily_return_pct"] = ((close.iloc[-1] / close.iloc[-2]) - 1) * 100
else:
indicators["daily_return_pct"] = 0.0

# Multi-period returns for relative strength (Issue #29)
if len(close) >= 20:
Expand All @@ -150,7 +174,8 @@ def _indicators_for_symbol(self, df: pd.DataFrame) -> dict[str, float]:
indicators["pct_from_52w_high"] = ((current_price / high_252) - 1) * 100
indicators["pct_from_52w_low"] = ((current_price / low_252) - 1) * 100

return indicators
# Filter out NaN values to prevent downstream issues
return {k: v for k, v in indicators.items() if not (isinstance(v, float) and math.isnan(v))}

def _support_resistance(
self,
Expand Down