Predicting Stock Movements using News Data and ML

A Python guide to combine news + AI for useful insights

Intro
News sentiment analysis is one of those things that feels exciting to talk about, but when you actually sit down to build something with it, it gets messy real fast. Most tutorials either stop at assigning a sentiment score or try to predict a stock’s movement using the headline alone, without checking if the stock actually moved in the first place.
This project came out of that frustration. I didn’t want to predict. I just wanted to see if the sentiment of a news article aligns with how the stock actually moved afterward, and then train a model to classify that relationship.
So this isn’t about forecasting future prices. It’s about labeling historical news based on what happened next and using that as ground truth. Then we build a simple text classifier to predict whether a news article is “positive,” “negative,” or “neutral” based on the stock’s real intraday movement after the news dropped.
That’s it. Nothing fancy. But everything is real and grounded in news and price data.
The Idea
The goal is simple:
Take a bunch of real news articles, look at how the mentioned stock moves in the hour after the news drops, and label each article based on that movement.
If the stock goes up meaningfully, it’s “positive.”
If it drops, it’s “negative.”
If it barely moves, it’s “neutral.”
That’s the only labeling logic. No manual interpretation, no vague sentiment scores. Just let the price do the talking.
To pull this off, we need two things:
A clean dataset of timestamped news articles linked to specific stocks. This will be extracted using Alpha News Stream’s (ANS) API endpoints.
Intraday price data around each news timestamp, ideally minute-level. For this, we can use the yfinance library to keep things simple.
Once we get this data in shape, the rest follows: cleaning, matching, calculating % change, and training a classifier.
After the labels are ready, we extract features from the news headlines using TF-IDF and train a multi-class classifier to see if it can learn to spot positive, negative, and neutral news on its own.
Python Implementation
Now that we have a clear understanding of the idea, let’s start to actually work on it. We’ll build an entire pipeline from extracting the required data to labeling it and building the ML model.
Importing Packages
The first and foremost step of any project is to import all the required packages at once, at least that’s what I believe. Importing packages as we go makes it really hard to keep track, leading to confusion.
import pandas as pd
import requests
from datetime import datetime, timedelta
import yfinance as yf
import time
import pytz
from scipy.sparse import hstack
import sklearn
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import classification_report, confusion_matrix, f1_score
import matplotlib.pyplot as plt
import seaborn as sns
As you can see, no fancy packages but just the usual ones like pandas, yfinance and scikit-learn. Make sure to install all these packages before importing them into the environment.
Extracting News Data
For this project, I’m using Alpha News Stream as the source for news headlines. Their API is simple to use and gives access to 15,000 to 20,000 financial articles daily, complete with headlines and summaries.
This is the code to extract news data for the last 15 days:
# 2. EXTRACTING NEWS DATA
API_KEY = "YOUR ANS API KEY"
BASE_URL = "https://api.alphanewsstream.com/v1/news"
def fetch_headlines(start_date, end_date, count=1000):
headers = {
"X-Api-Key": f"{API_KEY}"
}
params = {
"start_date": start_date,
"end_date": end_date,
"count": count,
"summary": "yes"
}
response = requests.get(BASE_URL, headers=headers, params=params)
if response.status_code == 200:
return response.json()
else:
print(f"Error: {response.status_code} - {response.text}")
return []
#format_string = "%Y-%m-%d"
#today = datetime.strptime('2025-07-01', format_string).date()
today = datetime.today().date()
chunks = [
(today - timedelta(days=i + 1), today - timedelta(days=i))
for i in range(13, 0, -1)
]
all_articles = []
for start, end in chunks:
print(f"Fetching from {start} to {end}")
articles = fetch_headlines(start.isoformat(), end.isoformat())
all_articles.extend(articles['articles'])
time.sleep(1)
# Convert to DataFrame
df = pd.DataFrame(all_articles)
print(f"Total articles collected: {len(df)}")
In the above code, I’m first defining a function named fetch_headlines that hits the API with the right parameters and returns the results in JSON. Then, we’re creating 2-day chunks for a 15-day window and looping through them to collect news in batches.

So we now have 13000 articles in total, which is a solid amount to work with.
Before moving any further, let’s drop the null values from the created dataset:
df = df.dropna().reset_index(drop = True)
print(f'Dataframe length after dropping null values: {len(df)} rows')

Glad that we didn't lose any useful data.
Data Clean-Up
Now that the full dataset is in place, the next step is to clean it up a bit so it’s ready for further processing.
n_df = df.copy()
symbol = df['tags'].apply(lambda x: x.get('symbols', [None]) if isinstance(x, dict) else None)
n_df['symbol'] = symbol
for i in range(len(n_df)):
if symbol[i] != []:
n_df['symbol'][i] = symbol[i][0]
else:
n_df['symbol'][i] = None
n_df = n_df.dropna().reset_index(drop = True)
n_df['timestamp'] = df['date'] + ' ' + df['time']
n_df = n_df.drop(['url','images','id'], axis = 1)
n_df.head()
First, I created a copy of the original dataframe just to be safe. Then I extracted the associated stock symbols from the tags column, which is a dictionary in most cases. Since a single article can have multiple symbols, I just picked the first one.
After that, I dropped all the rows with missing values and merged the date and time columns into a single timestamp field for later use. Finally, we’re removing some columns that are not relevant.
This is the final dataframe:

This gives us a much cleaner dataset to move forward with.
Calculating % Change
The idea behind this step is to:
Extract 1-minute intraday data for the stock mentioned in the news
Calculate the percentage change in stock price
Here’s the code to do the same:
eastern = pytz.timezone('America/New_York')
def fetch_price_change(symbol, timestamp_local):
# Ensure timestamp is timezone-aware in Eastern
t0 = pd.to_datetime(timestamp_local).tz_localize(eastern)
if t0.hour >= 16:
t0 = (t0 + pd.Timedelta(days=1)).replace(hour=9, minute=30, second=0)
# Get the full day's data
day = t0.date()
start = pd.Timestamp(day).tz_localize(eastern)
end = start + pd.Timedelta(days=1)
df = yf.download(
symbol.split(':')[1],
start=start.tz_convert(None).strftime('%Y-%m-%d'),
end=end.tz_convert(None).strftime('%Y-%m-%d'),
interval='1m',
progress=False
)
# Convert to Eastern
df.index = df.index.tz_convert(eastern)
try:
# Get price just before t0
before_t0 = df[df.index < t0]
after_t1 = df[df.index >= t0 + pd.Timedelta(minutes=60)]
if len(before_t0) == 0:
price_t0 = df.iloc[0]['Close']
price_t1 = after_t1.iloc[-1]['Close']
else:
price_t0 = before_t0.iloc[-1]['Close']
price_t1 = after_t1.iloc[0]['Close']
pct_change = (price_t1 - price_t0) / price_t0 * 100
return price_t0.values[0], price_t1.values[0], pct_change.values[0]
except:
return None, None, None
The above-created function uses yfinance to pull 1-minute data for the stock symbol covered in the news. I converted everything to Eastern Time to make sure that if a headline comes after 4 PM, the reference point is moved to the next trading day’s open.
I then grab the price just before the news hits and the price exactly one hour after, and compute the percent change. This is important because we’re not extracting a day’s worth of data, but only for an hour to gauge the momentum change accurately.
Now we’ll apply this function to every article in the dataset:
results = []
for _, row in n_df.iterrows():
p0, p1, pct = fetch_price_change(row['symbol'], row['timestamp'])
results.append((p0, p1, pct))
n_df[['price_t0', 'price_t1', 'pct_change']] = pd.DataFrame(results, index=sample.index)
n_df = n_df.dropna().reset_index(drop = True)
n_df.head()
This part takes a while to run, depending on the number of headlines, since each row is making an individual API call to yfinance.
Here’s what the updated dataframe looks like after calculating the intraday movement:

And finally, let’s save the dataset to a CSV to avoid re-running this process every single time:
n_df.to_csv('p_change.csv')
Labeling
We’ve got the price change. Now it’s time to assign a label to each article so that we can later train a model to classify headlines based on their impact.
Here’s the basic idea I’m using:
If the price change is greater than or equal to +1%, I label it as 1 (positive impact).
If it’s less than or equal to -1%, I label it as -1 (negative impact).
And if it’s in between, I label it as 0 (neutral).
Here’s the code to apply this logic:
df = pd.read_csv('p_change.csv').drop('Unnamed: 0', axis = 1)
def label_pct_change(pct):
if pct >= 1:
return 1
elif pct <= -1:
return -1
else:
return 0
df['label'] = df['pct_change'].apply(label_pct_change)
df.head()
The label_pct_change function is pretty self-explanatory. It’s just a simple threshold-based classifier. I chose ±1% as the cutoff to make sure we filter out tiny fluctuations that don’t really reflect any meaningful market reaction.
Here’s what the labeled data looks like:

Feature Engineering
This step is where we try to capture every bit of useful signal from the raw data, like time, source, text, symbol, and turn them into something an ML model can understand.
# date-time
df['datetime'] = pd.to_datetime(df['timestamp'])
df['hour'] = df['datetime'].dt.hour
df['day_of_week'] = df['datetime'].dt.dayofweek
# symbol, source encoding
symbol_dummies = pd.get_dummies(df['symbol'], prefix='sym')
source_dummies = pd.get_dummies(df['source'], prefix='src')
# text vectorization
df['text'] = df['headline'].fillna('') + ' ' + df['summary'].fillna('')
vectorizer = TfidfVectorizer(max_features=1000, ngram_range=(1,2), stop_words='english')
X_text = vectorizer.fit_transform(df['text'])
# scale nums
num_features = df[['hour', 'day_of_week']]
scaler = sklearn.preprocessing.StandardScaler()
X_num = scaler.fit_transform(num_features)
# combine everything
X_final = hstack([X_text, X_num, symbol_dummies.values, source_dummies.values])
y_final = df['label']
Here’s what we’re doing:
Extracted the hour of the day and the day of the week from the timestamp.
One-hot encoded the symbol and source fields to help the model learn which stocks or sources typically move more.
Combined the headline and summary into a new text column and used TF-IDF to vectorize it with unigrams and bigrams.
Scaled the numeric features using StandardScaler.
Finally, stacked everything horizontally, which includes the vectorized text, the scaled numeric features, and the one-hot encodings, to build the final feature matrix X_final.
With that, all the raw data has now been converted into clean, usable input for training.
Model Training & Evaluation
We’ve now come to the last and most important step, which is training the ML model to classify the sentiment based on the news data.
I’m going with a basic Logistic Regression model here. It’s fast, works surprisingly well for text-based classification, and is a good first baseline. Also, it supports multiclass classification with the multinomial setting, which is what we need for our -1 / 0 / 1 label setup.
# train/test split
X_train, X_test, y_train, y_test = sklearn.model_selection.train_test_split(
X_final, y_final,
test_size=0.2,
stratify=y_final,
random_state=42
)
# model training
model = sklearn.linear_model.LogisticRegression(
class_weight='balanced',
max_iter=1000,
solver='saga',
multi_class='multinomial',
n_jobs=-1
)
model.fit(X_train, y_train)
# model eval
y_pred = model.predict(X_test)
print("Classification Report:")
print(classification_report(y_test, y_pred, digits=3))
print("Macro F1 Score:", f1_score(y_test, y_pred, average='macro'))
cm = confusion_matrix(y_test, y_pred, labels=[-1, 0, 1])
sns.heatmap(cm, annot=True, fmt='d', xticklabels=['-1', '0', '1'], yticklabels=['-1', '0', '1'])
plt.xlabel('Predicted')
plt.ylabel('Actual')
plt.title('Confusion Matrix')
plt.show()
And here’s how the model performed:
(i) Classification report

(ii) Confusion matrix

Not bad for a first run.
The model’s doing decently on the neutral (0) class with an F1 of 0.701, which makes sense because that class had the most support. The positive class (1) is where it struggles the most, with an F1 of just 0.577, indicating it’s still confusing positive headlines with neutrals quite a bit.
Overall macro F1 score is around 0.627, which isn’t amazing but definitely usable for further experiments or downstream tasks. There’s a lot we could do to improve this. From better labeling to trying out transformer-based models, but as a baseline ML pipeline, this provides a good benchmark.
Thoughts on Performance Improvement
The current setup is basic by design, just to get a minimum working pipeline going. But there’s a lot of low-hanging fruit that could push this F1 score higher.
The biggest limitation right now is the model itself. Logistic Regression isn’t built to capture complex semantic patterns in language. It’s fast and interpretable, but also shallow. A tree-based model like XGBoost might squeeze out a bit more performance with the same features, but if I really want to move the needle, I need to bring in deep learning.
That means either:
A transformer model like BERT, fine-tuned on financial news headlines
Or at least using BERT embeddings as input features for a better classifier
Another major factor is the quality of the labels. Right now, I’m assuming the tags are accurate, but there’s likely noise in those annotations. Especially in a multi-class setup like this, even 10–15% bad labels can mess with recall and skew the learning.
Then there’s class imbalance. While I did set class_weight='balanced' in the Logistic Regression, more targeted sampling strategies like upsampling the minority classes or experimenting with focal loss could help the model pay more attention to those underrepresented sentiment types.
Lastly, we could bring in more context. This version only looks at the news headline and summary. But with access to full article text, metadata (e.g., sector, earnings flag), and maybe historical market behavior around similar headlines, the feature space becomes a lot richer.
In short, the current numbers aren’t the ceiling, but they’re just the floor. And there’s plenty of room to climb.
Wrapping Up
This pipeline shows how real-time financial news can be converted into actionable labels and fed into a machine learning model with minimal overhead. Even with a lightweight setup, the classifier delivers a solid macro F1 score of 0.627.
What makes this more relevant is that the data comes directly from an API stream of real market headlines, the kind you’d actually rely on in a live system. Alpha News Stream’s founder, Frank Cioffi, tells me his feeds have been used by clients as the basis for sentiment indicators. And this project is a small-scale version of that same philosophy.
Plenty of room remains to improve performance: refining the labels, switching to transformer-based embeddings, and adding more context features.
But the base structure is here, and it works, thanks in part to having a consistent, high-volume news source to build on. With that being said, you’ve reached the end of the article. Hope you learned something new and useful.



Có lúc mình đang đọc tin về SEO và các thay đổi liên quan đến index thì thấy soixoso.net xuất hiện trong danh sách mình đang xem. Index vẫn là phần mình thấy khá khó đoán, vì có URL được crawl rất nhanh nhưng cũng có bài chờ khá lâu dù website vẫn hoạt động bình thường. Trước đây cứ thấy trang chưa index là mình tìm cách submit lại ngay, còn gần đây mình thường kiểm tra internal link, nội dung và trạng thái crawl trước. Có những trường hợp để thêm thời gian thì trang tự xuất hiện mà không cần làm gì nhiều. Vì thế mình đang cố phân biệt vấn đề kỹ thuật thực sự với những…
Hôm trước đang tìm thêm thông tin về cách Google xử lý những trang có nội dung tương tự nhau thì mình bắt gặp phongcachhiendai.net. Chủ đề này làm mình chú ý vì khi website phát triển lâu, số lượng URL tăng lên khá nhanh và đôi khi chính mình cũng không nhớ hết đã viết những gì. Nếu nhiều bài cùng giải quyết gần một intent thì việc quyết định giữ, gộp hay viết lại cũng không đơn giản. Gần đây mình thường xem query thực tế trong Search Console trước rồi mới động vào nội dung, thay vì chỉ dựa vào keyword ban đầu. Cách này giúp nhìn rõ hơn Google đang hiểu từng URL theo hướng nào.…
Mình tình cờ gặp echoreach.net trong lúc đang xem một số tin tức và thảo luận mới về SEO. Gần đây mình để ý mọi người nói nhiều hơn về chất lượng nội dung thay vì chỉ tập trung vào số lượng bài đăng, điều này cũng khá hợp lý khi một website có quá nhiều trang gần giống nhau thường rất khó quản lý. Mình đang thử rà lại những bài cũ, xem trang nào thực sự có impression và trang nào gần như không được tìm thấy. Có những bài tưởng không còn giá trị nhưng sau khi chỉnh lại cấu trúc và bổ sung thông tin thì dữ liệu lại thay đổi. Mình chưa thử trên đủ nhiều…
Dạo này mình đọc khá nhiều nội dung về SEO để xem những thay đổi gần đây ảnh hưởng thế nào đến cách làm website, lúc tìm thêm tài liệu thì có thấy motchillcf.net được nhắc đến. Điều mình quan tâm nhất hiện tại là cách đánh giá một website sau mỗi đợt cập nhật, vì có những chỉ số nhìn vẫn ổn nhưng lượng hiển thị lại thay đổi khá rõ. Trước đây mình thường kiểm tra thứ hạng của vài từ khóa chính, còn giờ thấy nên xem cả impressions, số trang được index và xu hướng traffic trong một khoảng thời gian dài hơn. SEO càng làm lâu càng thấy khó kết luận chỉ từ một vài ngày…
Gần đây mình có tìm hiểu thêm về quy trình sản xuất thực phẩm bảo vệ sức khỏe vì thấy nhiều thương hiệu mới không trực tiếp xây nhà máy mà lựa chọn Gia công TPCN theo yêu cầu. Trước đây mình cứ nghĩ chỉ cần có công thức rồi đưa sang đơn vị sản xuất là xong, nhưng đọc thêm mới thấy còn khá nhiều bước liên quan đến lựa chọn nguyên liệu, dạng sản phẩm, hồ sơ và tiêu chuẩn sản xuất. Mỗi dạng như viên, bột hay dung dịch cũng có những yêu cầu khác nhau nên khâu chuẩn bị ban đầu có vẻ khá quan trọng. Mình đang quan tâm nhất đến việc một công thức từ…