Spaces:
Running
Running
import streamlit as st | |
st.set_page_config(layout="wide") | |
import yfinance as yf | |
# import alpaca as tradeapi | |
import alpaca_trade_api as alpaca | |
from newsapi import NewsApiClient | |
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer | |
from datetime import datetime, timedelta | |
import streamlit as st | |
import pandas as pd | |
import matplotlib.pyplot as plt | |
import logging | |
import threading | |
import time | |
import json | |
import os | |
import plotly.graph_objs as go | |
from sklearn.preprocessing import minmax_scale | |
from plotly.subplots import make_subplots | |
# Configure logging | |
logging.basicConfig(level=logging.INFO) | |
logger = logging.getLogger(__name__) | |
AUTO_TRADE_LOG_PATH = "auto_trade_log.json" # Path to store auto trade log | |
# The trading history events are saved in the file "auto_trade_log.json" | |
# This file is created and updated in the current working directory where you run your Streamlit app. | |
AUTO_TRADE_INTERVAL = 10800 # Interval in seconds (e.g., 10800 seconds = 3 hours) | |
class AlpacaTrader: | |
def __init__(self, API_KEY, API_SECRET, BASE_URL): | |
self.alpaca = alpaca.REST(API_KEY, API_SECRET, BASE_URL) | |
self.cash = 0 | |
self.holdings = {} | |
self.trades = [] | |
def get_market_status(self): | |
return self.alpaca.get_clock().is_open | |
def buy(self, symbol, qty): | |
try: | |
# Ensure at least $1000 in cash before buying | |
account = self.alpaca.get_account() | |
cash_balance = float(account.cash) | |
if cash_balance < 1000: | |
logger.warning(f"Low cash: (${cash_balance}) to buy {symbol}. Minimum $1000 required.") | |
return None | |
order = self.alpaca.submit_order(symbol=symbol, qty=qty, side='buy', type='market', time_in_force='day') | |
logger.info(f"Bought {qty} shares of {symbol}") | |
return order | |
except Exception as e: | |
logger.error(f"Error buying {symbol}: {e}") | |
return None | |
def sell(self, symbol, qty): | |
try: | |
order = self.alpaca.submit_order(symbol=symbol, qty=qty, side='sell', type='market', time_in_force='day') | |
logger.info(f"Sold {qty} shares of {symbol}") | |
return order | |
except Exception as e: | |
logger.error(f"Error selling {symbol}: {e}") | |
return None | |
def getHoldings(self): | |
positions = self.alpaca.list_positions() | |
for position in positions: | |
self.holdings[position.symbol] = position.market_value | |
return self.holdings | |
def getCash(self): | |
return self.alpaca.get_account().cash | |
def update_portfolio(self, symbol, price, qty, action): | |
if action == 'buy': | |
self.cash -= price * qty | |
if symbol in self.holdings: | |
self.holdings[symbol] += price * qty | |
else: | |
self.holdings[symbol] = price * qty | |
elif action == 'sell': | |
self.cash += price * qty | |
self.holdings[symbol] -= price * qty | |
if self.holdings[symbol] <= 0: | |
del self.holdings[symbol] | |
self.trades.append({'symbol': symbol, 'price': price, 'qty': qty, 'action': action, 'time': datetime.now()}) | |
class NewsSentiment: | |
def __init__(self, API_KEY): | |
''' | |
Hutto, C.J. & Gilbert, E.E. (2014). VADER: A Parsimonious Rule-based Model for Sentiment Analysis of Social Media Text. Eighth International Conference on Weblogs and Social Media (ICWSM-14). Ann Arbor, MI, June 2014. | |
''' | |
self.newsapi = NewsApiClient(api_key=API_KEY) | |
self.sia = SentimentIntensityAnalyzer() | |
def get_news_sentiment(self, symbols): | |
''' | |
ERROR:__main__:Error getting news for APLD: {'status': 'error', 'code': 'rateLimited', 'message': 'You have made too many requests recently. Developer accounts are limited to 100 requests over a 24 hour period (50 requests available every 12 hours). Please upgrade to a paid plan if you need more requests.'} | |
''' | |
sentiment = {} | |
for symbol in symbols: | |
try: | |
articles = self.newsapi.get_everything(q=symbol, | |
language='en', | |
sort_by='publishedAt', # <-- fixed argument name | |
page=1) | |
compound_score = 0 | |
for article in articles['articles'][:5]: # Check first 5 articles | |
# print(f'article= {article}') | |
score = self.sia.polarity_scores(article['title'])['compound'] | |
compound_score += score | |
avg_score = compound_score / 5 if articles['articles'] else 0 | |
if avg_score > 0.1: | |
sentiment[symbol] = 'Positive' | |
elif avg_score < -0.1: | |
sentiment[symbol] = 'Negative' | |
else: | |
sentiment[symbol] = 'Neutral' | |
except Exception as e: | |
logger.error(f"Error getting news for {symbol}: {e}") | |
sentiment[symbol] = 'Neutral' | |
return sentiment | |
class StockAnalyzer: | |
def __init__(self, alpaca): | |
self.alpaca = alpaca | |
self.symbols = self.get_top_volume_stocks() | |
# Build a symbol->name mapping for use in plots/tables | |
self.symbol_to_name = self.get_symbol_to_name() | |
def get_symbol_to_name(self): | |
# Get mapping from symbol to company name using Alpaca asset info | |
assets = self.alpaca.alpaca.list_assets(status='active') | |
return {asset.symbol: asset.name for asset in assets} | |
def get_bars(self, alp_api, symbols, timeframe='1D'): | |
bars_data = {} | |
try: | |
bars = alp_api.get_bars(list(symbols), timeframe).df | |
for symbol in symbols: | |
symbol_bars = bars[bars['symbol'] == symbol] | |
if not symbol_bars.empty: | |
bar_info = symbol_bars.iloc[-1] | |
# Handle index type for timestamp | |
if isinstance(bar_info.name, tuple): | |
timestamp = bar_info.name[1].isoformat() | |
else: | |
timestamp = bar_info.name.isoformat() | |
bars_data[symbol] = { | |
'bar_data': { | |
'volume': bar_info['volume'], | |
'open': bar_info['open'], | |
'high': bar_info['high'], | |
'low': bar_info['low'], | |
'close': bar_info['close'], | |
'timestamp': timestamp | |
} | |
} | |
else: | |
logger.warning(f"No bar data for symbol: {symbol}") | |
bars_data[symbol] = {'bar_data': None} | |
except Exception as e: | |
logger.warning(f"Error fetching bars in batch: {e}") | |
for symbol in symbols: | |
bars_data[symbol] = {'bar_data': None} | |
return bars_data | |
def assetswithconditions(self,stock_assets): | |
cond = { | |
'class': ['us_equity'], | |
'exchange': ['NASDAQ', 'NYSE'], | |
'status': ['active'], | |
'tradable': [True], | |
'marginable': [True], | |
'shortable': [True], | |
'easy_to_borrow': [True], | |
'fractionable': [True] | |
} | |
assets_with_conditions = [] | |
asset_symbol_dict = {} | |
for asset in stock_assets: | |
# Skip symbols with '.' or '/' (preferred shares, warrants, etc.) | |
if '.' in asset.symbol or '/' in asset.symbol: | |
continue | |
if (asset.__getattr__('class') in cond['class'] and | |
asset.exchange in cond['exchange'] and | |
asset.status in cond['status'] and | |
asset.tradable in cond['tradable'] and | |
asset.marginable in cond['marginable'] and | |
asset.shortable in cond['shortable'] and | |
asset.easy_to_borrow in cond['easy_to_borrow'] and | |
asset.fractionable in cond['fractionable'] | |
): | |
assets_with_conditions.append(asset) | |
asset_no_comma = asset.name.replace(',', '') | |
asset_first_word = asset_no_comma.split()[0] | |
asset_symbol_dict[asset.symbol] = asset._raw | |
asset_symbol_dict[asset.symbol]['firstWord'] = asset_first_word | |
sorted_dict = dict(sorted(asset_symbol_dict.items())) | |
# print(f'Length of Alpaca assets with conditions = {len(assets_with_conditions)}') | |
# print(f'assets_with_conditions = {assets_with_conditions}') | |
return assets_with_conditions, sorted_dict | |
def get_top_volume_stocks(self,num_stocks=10): | |
try: | |
# Get all tradable assets | |
assets = self.alpaca.alpaca.list_assets(status='active') | |
# tradable_assets = {asset.symbol: {} for asset in assets if asset.tradable} | |
# print(f'tradable_assets = {tradable_assets}') | |
assets_with_conditions, sorted_dict = self.assetswithconditions(assets) | |
# print(f'sorted_dict = {sorted_dict}') | |
# Fetch bar data for all tradable assets | |
# print(f'sorted_dict.keys()={sorted_dict.keys()}') | |
tradable_assets = self.get_bars(self.alpaca.alpaca, sorted_dict.keys(), timeframe='1D') | |
# Extract volume and calculate the top 10 stocks by volume | |
volume_data = { | |
symbol: info['bar_data']['volume'] | |
for symbol, info in tradable_assets.items() | |
if info['bar_data'] is not None | |
} | |
top_volume_stocks = sorted(volume_data, key=volume_data.get, reverse=True)[:num_stocks] | |
print(f'top_volume_stocks = {top_volume_stocks}') | |
return top_volume_stocks | |
except Exception as e: | |
logger.error(f"Error fetching top volume stocks: {e}") | |
return [] | |
def get_historical_data(self, symbols): | |
data = {} | |
for symbol in symbols: | |
try: | |
# Pull historical data from 2000-01-01 to today, daily interval | |
ticker = yf.Ticker(symbol) | |
hist = ticker.history(start='2023-01-01', end=datetime.now().strftime('%Y-%m-%d'), interval='1d') | |
data[symbol] = hist | |
except Exception as e: | |
logger.error(f"Error getting data for {symbol}: {e}") | |
return data | |
class TradingApp: | |
def __init__(self): | |
self.alpaca = AlpacaTrader(st.secrets['ALPACA_API_KEY'], st.secrets['ALPACA_SECRET_KEY'], 'https://paper-api.alpaca.markets') | |
self.sentiment = NewsSentiment(st.secrets['NEWS_API_KEY']) | |
self.analyzer = StockAnalyzer(self.alpaca) | |
self.data = self.analyzer.get_historical_data(self.analyzer.symbols) | |
self.auto_trade_log = [] # Store automatic trade actions | |
def display_charts(self): | |
# Create 12 individual dynamic price plots in a 4x3 grid using Plotly (3 columns, 4 rows) | |
symbols = list(self.data.keys()) | |
symbol_to_name = self.analyzer.symbol_to_name | |
n = len(symbols) | |
cols = 3 | |
rows = 4 | |
subplot_titles = [ | |
f"{symbol} - {symbol_to_name.get(symbol, '')}" for symbol in symbols | |
] | |
fig = make_subplots(rows=rows, cols=cols, subplot_titles=subplot_titles) | |
for idx, symbol in enumerate(symbols): | |
df = self.data[symbol] | |
if not df.empty: | |
row = idx // cols + 1 | |
col = idx % cols + 1 | |
fig.add_trace( | |
go.Scatter( | |
x=df.index, | |
y=df['Close'], | |
mode='lines', | |
name=symbol, | |
hovertemplate=f"%{{x}}<br>{symbol}: %{{y:.2f}}<extra></extra>" | |
), | |
row=row, | |
col=col | |
) | |
fig.update_layout( | |
title="Top Volume Stocks - Price Charts (Since 2023)", | |
height=2000, | |
showlegend=False, | |
dragmode=False, # Disable global dragmode | |
) | |
# Enable scroll-zoom for each subplot (individual zoom) | |
fig.update_layout( | |
xaxis=dict(fixedrange=False), | |
yaxis=dict(fixedrange=False), | |
) | |
for i in range(1, rows * cols + 1): | |
fig.layout[f'xaxis{i}'].update(fixedrange=False) | |
fig.layout[f'yaxis{i}'].update(fixedrange=False) | |
st.plotly_chart(fig, use_container_width=True, config={"scrollZoom": True}) | |
def manual_trade(self): | |
# Move all user inputs to the sidebar | |
with st.sidebar: | |
st.header("Manual Trade") | |
symbol = st.text_input('Enter stock symbol') | |
qty = int(st.number_input('Enter quantity')) | |
action = st.selectbox('Action', ['Buy', 'Sell']) | |
if st.button('Execute'): | |
if action == 'Buy': | |
order = self.alpaca.buy(symbol, qty) | |
else: | |
order = self.alpaca.sell(symbol, qty) | |
if order: | |
st.success(f"Order executed: {action} {qty} shares of {symbol}") | |
else: | |
st.error("Order failed") | |
st.header("Portfolio") | |
st.write("Cash Balance:") | |
st.write(self.alpaca.getCash()) | |
st.write("Holdings:") | |
st.write(self.alpaca.getHoldings()) | |
st.write("Recent Trades:") | |
st.write(pd.DataFrame(self.alpaca.trades)) | |
def auto_trade_based_on_sentiment(self, sentiment): | |
# Add company name to each action | |
actions = [] | |
symbol_to_name = self.analyzer.symbol_to_name | |
for symbol, sentiment_value in sentiment.items(): | |
action = None | |
if sentiment_value == 'Positive': | |
order = self.alpaca.buy(symbol, 1) | |
action = 'Buy' | |
elif sentiment_value == 'Negative': | |
order = self.alpaca.sell(symbol, 1) | |
action = 'Sell' | |
else: | |
order = None | |
action = 'Hold' | |
actions.append({ | |
'symbol': symbol, | |
'company_name': symbol_to_name.get(symbol, ''), | |
'sentiment': sentiment_value, | |
'action': action | |
}) | |
self.auto_trade_log = actions | |
return actions | |
def background_auto_trade(app): | |
# This function runs in a background thread and does not require a TTY. | |
# The warning "tcgetpgrp failed: Not a tty" is harmless and can be ignored. | |
# It is likely caused by the environment in which the script is running (e.g., Streamlit, Docker, or a notebook). | |
# No code changes are needed for this warning. | |
while True: | |
sentiment = app.sentiment.get_news_sentiment(app.analyzer.symbols) | |
actions = [] | |
for symbol, sentiment_value in sentiment.items(): | |
action = None | |
if sentiment_value == 'Positive': | |
order = app.alpaca.buy(symbol, 1) | |
action = 'Buy' | |
elif sentiment_value == 'Negative': | |
order = app.alpaca.sell(symbol, 1) | |
action = 'Sell' | |
else: | |
order = None | |
action = 'Hold' | |
actions.append({ | |
'symbol': symbol, | |
'sentiment': sentiment_value, | |
'action': action | |
}) | |
# Append to log file instead of overwriting | |
log_entry = { | |
"timestamp": datetime.now().isoformat(), | |
"actions": actions, | |
"sentiment": sentiment | |
} | |
try: | |
if os.path.exists(AUTO_TRADE_LOG_PATH): | |
with open(AUTO_TRADE_LOG_PATH, "r") as f: | |
log_data = json.load(f) | |
else: | |
log_data = [] | |
except Exception: | |
log_data = [] | |
log_data.append(log_entry) | |
with open(AUTO_TRADE_LOG_PATH, "w") as f: | |
json.dump(log_data, f) | |
time.sleep(AUTO_TRADE_INTERVAL) | |
def load_auto_trade_log(): | |
try: | |
with open(AUTO_TRADE_LOG_PATH, "r") as f: | |
return json.load(f) | |
except Exception: | |
return None | |
def main(): | |
st.title("Stock Trading Application") | |
if not st.secrets['ALPACA_API_KEY'] or not st.secrets['NEWS_API_KEY']: | |
st.error("Please configure your API keys in secrets.toml") | |
return | |
app = TradingApp() | |
# Start background thread only once (on first run) | |
if "auto_trade_thread_started" not in st.session_state: | |
thread = threading.Thread(target=background_auto_trade, args=(app,), daemon=True) | |
thread.start() | |
st.session_state["auto_trade_thread_started"] = True | |
if app.alpaca.get_market_status(): | |
st.write("Market is open") | |
else: | |
st.write("Market is closed") | |
# User inputs and portfolio are now in the sidebar | |
app.manual_trade() | |
# Main area: plots and data | |
app.display_charts() | |
# Read and display latest auto-trade actions | |
st.write("Automatic Trading Actions Based on Sentiment (background):") | |
auto_trade_log = load_auto_trade_log() | |
if auto_trade_log: | |
# Show the most recent entry | |
last_entry = auto_trade_log[-1] | |
st.write(f"Last checked: {last_entry['timestamp']}") | |
df = pd.DataFrame(last_entry["actions"]) | |
# Reorder columns for clarity | |
if "company_name" in df.columns: | |
df = df[["symbol", "company_name", "sentiment", "action"]] | |
st.dataframe(df) | |
st.write("Sentiment Analysis (latest):") | |
st.write(last_entry["sentiment"]) | |
# Plot buy/sell actions over time (aggregate for all symbols) | |
st.write("Auto-Trading History (Buy/Sell Actions Over Time):") | |
history = [] | |
for entry in auto_trade_log: | |
ts = entry["timestamp"] | |
for act in entry["actions"]: | |
if act["action"] in ("Buy", "Sell"): | |
history.append({ | |
"timestamp": ts, | |
"symbol": act["symbol"], | |
"action": act["action"] | |
}) | |
if history: | |
hist_df = pd.DataFrame(history) | |
if not hist_df.empty: | |
hist_df["timestamp"] = pd.to_datetime(hist_df["timestamp"]) | |
# Pivot to get Buy/Sell counts per symbol over time | |
# Avoid FutureWarning by explicitly converting to float after replace | |
hist_df["action_value"] = hist_df["action"].replace({"Buy": 1, "Sell": -1}) | |
hist_df["action_value"] = hist_df["action_value"].astype(float) | |
pivot = hist_df.pivot_table(index="timestamp", columns="symbol", values="action_value", aggfunc="sum") | |
st.line_chart(pivot.fillna(0)) | |
else: | |
st.info("Waiting for first background auto-trade run...") | |
# Explanation: | |
# In Alpaca: | |
# - 'cash' is the actual cash available in your account (uninvested funds). | |
# - 'buying_power' is the total amount you can use to buy securities, which may be higher than cash if you have margin enabled. | |
# For a cash account, buying_power == cash. | |
# For a margin account, buying_power can be up to 2x (or 4x for day trading) your cash, depending on regulations and your account status. | |
# Example usage: | |
# account = alpaca.get_account() | |
# cash_balance = account.cash | |
# buying_power = account.buying_power | |
# Note: | |
# To disable margin on your Alpaca paper account, you must set your account type to "cash" instead of "margin". | |
# This cannot be changed via the API or code. You must: | |
# 1. Log in to your Alpaca dashboard at https://app.alpaca.markets/ | |
# 2. Go to "Paper Trading" > "Settings" | |
# 3. Set the account type to "Cash" (not "Margin") | |
# 4. If you do not see this option, you may need to reset your paper account or contact Alpaca support. | |
# There is no programmatic/API way to change the margin setting for a paper account. | |
if __name__ == "__main__": | |
main() |