Quick answer: Web scraping stock prices with Python involves retrieving an authorized page, extracting quote fields, and checking the record before export. This tutorial uses Requests, Beautiful Soup and a synthetic HTML example. For jobs requiring a specific network route or session behavior, it also shows how to configure a Socks5.IO SOCKS5 proxy. Record the source timestamp, currency, exchange and market session; routing does not determine quote freshness or data-use rights.
Copy the complete HTML and Python examples below into local files. The DEMO instrument is fictional; reference-site images illustrate page structure rather than current market quotes.
Example scope: Documentation reviewed October 19, 2026. The reproducible example uses synthetic data, not a financial-site or Socks5.IO performance test.
Choose the right source before scraping stock prices
A number on a financial page is not necessarily an executable price. Decide whether you need a delayed displayed quote, a previous close, an intraday series or licensed real-time data before writing a collector.
| What you need | Appropriate starting point | Important check |
|---|---|---|
| A small research snapshot from an approved page | Static HTML with stable fields | Permission, quote time, currency and session |
| Daily historical prices for backtesting | Documented download or market-data API | Adjustments, corporate actions and historical coverage |
| Quotes that appear only after rendering | An authorized rendered page or documented API | Raw response versus rendered DOM; source permissions |
| Low-latency trading or broad redistribution | A feed licensed for that use | Entitlements, latency, exchange coverage and redistribution rights |
An API and an HTML scraper are different interfaces. For example, Alpha Vantage’s quote documentation distinguishes its quote endpoint and data entitlements. Calling an API with Requests is not DOM scraping, and its freshness still depends on the service and subscription.
Review the source’s terms, data license, robots rules and permitted request rate. Public visibility alone does not establish reuse rights. Google Finance’s disclaimer is a concrete example of a provider specifying data delays and restrictions on copying or commercial use. Do not assume that permission to view a page includes permission to build a commercial feed from it.
Define a useful stock-market record
To scrape stock market data reliably, identify the instrument and the meaning of the price before storing it. Keep at least these fields:
| Field | Why it matters |
|---|---|
| Symbol and exchange | A ticker alone may be ambiguous across venues |
| Currency | A numeric amount without its unit cannot be compared safely |
| Price and volume in shares | Preserve decimal prices and verify whether volume is raw or abbreviated |
| Source quote time | When the source says the quote applies |
| Retrieval time in UTC | When your collector obtained the page |
| Market session | Regular, pre-market, after-hours or unknown |
| Delay status | Source-declared delay, unknown, or synthetic for a fixture |
| Source URL and source kind | Provenance; keeps demonstrations out of production data |
Retrieving a page at 16:10 does not turn a 16:00 quote into a 16:10 trade. A weekend fetch may contain Friday’s last price. Do not replace missing data with zero or substitute the previous close when the latest-price selector disappears.

How to scrape stock market data with Python, step by step
Create an empty local folder for the tutorial. Save the HTML in step 2 and the complete Python block in step 3 into that folder.
1. Install the dependencies
Use Python 3.10 or later. Open a terminal in your tutorial folder and create an isolated environment:
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install "requests[socks]>=2.32,<3" "beautifulsoup4>=4.13,<5"
On macOS or Linux, use python3 -m venv .venv and .venv/bin/python instead. Subsequent commands below use python; run them with your environment’s executable, or activate the environment first.
The dependencies are requests[socks] and beautifulsoup4. Python’s built-in csv, decimal and datetime modules handle export, decimal arithmetic and timestamps. Pandas is optional for later analysis, not a prerequisite for extracting one quote.
2. Inspect the HTML you will parse
Save the complete block below as quote-demo.html, with UTF-8 encoding:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Fictional stock quote</title></head>
<body>
<h1>DEMO Corporation — synthetic test instrument</h1>
<p>Not a real security or a market quote.</p>
<span class="quote-price" data-value="123.45">123.45</span>
<span class="quote-volume" data-value="1234500">1,234,500</span>
<time class="quote-time" datetime="2026-10-15T16:00:00-04:00">
Synthetic timestamp
</time>
</body>
</html>
Its symbol is DEMO, exchange is TEST and currency is USD. All values are fabricated training inputs, not a quote for a real company. The timestamp has an explicit offset, which lets the parser convert it to UTC without guessing the exchange’s timezone.
On an approved real page, inspect the raw HTML response as well as the rendered browser view. A browser can display data that a Requests response does not contain. Prefer a stable semantic attribute over a generated CSS class, and verify whether a numeric value lives in text, content or another attribute.

3. Run the complete Python stock-price scraper
Save the entire Python block as quote_scraper.py, next to the HTML. It is complete, not an excerpt: imports, proxy setup, parsing, fetching, command-line options and CSV output are included.
import argparse
import csv
import ipaddress
import os
import re
import sys
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
from urllib.parse import quote, urlsplit
import requests
from bs4 import BeautifulSoup
def build_session(use_proxy=False):
session = requests.Session()
session.trust_env = False
session.headers['User-Agent'] = 'QuoteResearchTutorial/1.0'
if use_proxy:
host = os.environ['SOCKS5IO_HOST']
port = int(os.environ['SOCKS5IO_PORT'])
if not re.fullmatch(r'[A-Za-z0-9.-]+', host) or not 1 <= port <= 65535:
raise ValueError('Invalid proxy host or port')
user = quote(os.environ['SOCKS5IO_USER'], safe='')
password = quote(os.environ['SOCKS5IO_PASSWORD'], safe='')
proxy = f'socks5h://{user}:{password}@{host}:{port}'
session.proxies.update({'http': proxy, 'https': proxy})
return session
def is_local_url(url):
host = (urlsplit(url).hostname or '').lower().rstrip('.')
if host == 'localhost' or host.endswith('.localhost'):
return True
try:
address = ipaddress.ip_address(host)
except ValueError:
return False
mapped = getattr(address, 'ipv4_mapped', None)
return address.is_loopback or address.is_unspecified or bool(mapped and mapped.is_loopback)
def fetch_html(session, url):
parsed = urlsplit(url)
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError('Use a public quote URL without credentials, query or fragment')
local = is_local_url(url)
if local and any(session.proxies.values()):
raise ValueError('Local sources cannot use a remote proxy')
if parsed.scheme != 'https' and not (local and parsed.scheme == 'http'):
raise ValueError('Use HTTPS, or HTTP for the local tutorial server only')
with session.get(url, timeout=(5, 20), allow_redirects=False) as response:
if response.status_code == 429:
raise ValueError('HTTP 429: stop; review Retry-After and the source quota')
if response.status_code in {401, 403}:
raise ValueError(f'HTTP {response.status_code}: stop; check access rights')
if response.status_code != 200:
raise ValueError(f'HTTP {response.status_code}: inspect before continuing')
if 'text/html' not in response.headers.get('Content-Type', '').lower():
raise ValueError('Expected HTML, not an API response or file download')
return response.content
def field(soup, selector):
matches = soup.select(selector)
if len(matches) != 1:
raise ValueError('Each selector must match exactly one node')
node = matches[0]
for attr in ('data-value', 'content', 'datetime'):
if node.get(attr) is not None:
return node[attr].strip()
return node.get_text(' ', strip=True)
def number(raw, integer=False):
pattern = r'(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?'
if not re.fullmatch(pattern, raw):
raise ValueError('Unsupported number format or missing value')
value = Decimal(raw.replace(',', ''))
if integer and value != value.to_integral_value():
raise ValueError('Volume must be a whole number of shares')
if not integer and value <= 0:
raise ValueError('Price must be positive')
return str(int(value)) if integer else format(value, 'f')
def parse_quote(html, price_selector, volume_selector, time_selector):
soup = BeautifulSoup(html, 'html.parser')
raw_time = field(soup, time_selector)
source_time = datetime.fromisoformat(raw_time.replace('Z', '+00:00'))
if source_time.tzinfo is None:
raise ValueError('Quote timestamp needs a verified timezone offset')
return {'price': number(field(soup, price_selector)),
'volume_shares': number(field(soup, volume_selector), integer=True),
'quote_time_raw': raw_time,
'quote_time_utc': source_time.astimezone(timezone.utc).isoformat()}
def main():
parser = argparse.ArgumentParser()
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument('--fixture', type=Path)
source.add_argument('--url')
parser.add_argument('--approved', action='store_true')
parser.add_argument('--proxy', action='store_true')
parser.add_argument('--demo-http', action='store_true')
for name in ('symbol', 'exchange', 'currency', 'market-session', 'delay-status'):
parser.add_argument('--' + name, required=True)
parser.add_argument('--price-selector', default='.quote-price')
parser.add_argument('--volume-selector', default='.quote-volume')
parser.add_argument('--time-selector', default='.quote-time')
parser.add_argument('--out', type=Path, required=True)
args = parser.parse_args()
if args.fixture:
if args.proxy or args.demo_http:
parser.error('Fixture mode does not use a proxy or HTTP')
html, source_url, kind = args.fixture.read_bytes(), 'local:quote-demo.html', 'synthetic_fixture'
else:
if not args.approved:
parser.error('Review source permissions, terms and robots rules before --approved')
local_source = is_local_url(args.url)
if args.demo_http and not local_source:
parser.error('--demo-http is reserved for the local tutorial server')
if local_source and args.proxy:
parser.error('Local sources cannot use a remote proxy')
with build_session(args.proxy) as session:
html = fetch_html(session, args.url)
source_url = args.url
kind = 'synthetic_http_fixture' if local_source else 'user_approved_html'
row = {'symbol': args.symbol, 'exchange': args.exchange, 'currency': args.currency,
**parse_quote(html, args.price_selector, args.volume_selector, args.time_selector),
'market_session': args.market_session, 'delay_status': args.delay_status,
'retrieved_at_utc': datetime.now(timezone.utc).isoformat(),
'source_url': source_url, 'source_kind': kind}
with args.out.open('x', encoding='utf-8', newline='') as handle:
writer = csv.DictWriter(handle, fieldnames=list(row))
writer.writeheader()
writer.writerow(row)
print('Saved one validated record. Source kind:', kind)
if __name__ == '__main__':
try:
main()
except ValueError as error:
print('Stopped:', error, file=sys.stderr)
sys.exit(1)
except (KeyError, OSError, requests.RequestException):
print('Stopped: check HTTP/access status, selectors, fields, environment or output filename.', file=sys.stderr)
sys.exit(1)
Run this command from the same folder, using your virtual environment’s Python:
python quote_scraper.py --fixture quote-demo.html --symbol DEMO --exchange TEST --currency USD --market-session regular --delay-status synthetic --out demo-quotes.csv
The parser requires one matching node per selected field, checks numeric formatting and rejects source timestamps without timezone offsets. It does not verify that the page matches your supplied symbol, exchange or currency, or that the quote is fresh and belongs to the intended session. Verify those facts and your acceptable quote age separately. Existing output filenames are refused, so choose a new filename on repeat runs.
Beautiful Soup’s selector documentation explains the selection mechanism. The image below highlights the parsing functions; copy the complete text above to run the program.

Requiring exactly one match prevents a layout change from silently selecting an unrelated price. The number helper accepts US decimal formatting and grouped thousands; it rejects blanks, N/A, currency symbols, 1.2M and comma-decimal formats. Add a source-specific normalizer only after confirming that format and its units.
4. Inspect the exported record
The sample should produce these deterministic values:
symbol: DEMO
exchange: TEST
currency: USD
price: 123.45
volume_shares: 1234500
quote_time_utc: 2026-10-15T20:00:00+00:00
delay_status: synthetic
source_kind: synthetic_fixture
The CSV also includes the raw source timestamp, source URL, market session and actual retrieval timestamp. Your retrieval time will differ. Check the file as text first; spreadsheet software can otherwise reformat timestamps or identifiers without asking.

For a reproducible failure, run with a selector that does not exist:
python quote_scraper.py --fixture quote-demo.html --symbol DEMO --exchange TEST --currency USD --market-session regular --delay-status synthetic --price-selector ".missing-price" --out must-not-exist.csv
Expect Stopped: Each selector must match exactly one node, a nonzero exit code and no new output file. This checks missing-field handling, not the accuracy of real market data.
5. Exercise the HTTP path locally
To test fetching without sending traffic to a financial website, serve the same fictional page. In a second terminal, from your tutorial folder, run:
python -m http.server 8765 --bind 127.0.0.1
Leave that terminal open, then run the collector in your first terminal:
python quote_scraper.py --url http://127.0.0.1:8765/quote-demo.html --approved --demo-http --symbol DEMO --exchange TEST --currency USD --market-session regular --delay-status synthetic --out demo-http-quotes.csv
Expect source_kind to be synthetic_http_fixture. Stop the server with Ctrl+C when finished. Recognized localhost and loopback sources keep this label even if --demo-http is omitted. Their URLs are rejected with --proxy before proxy setup or any fetch; fetch_html also rejects them when given an explicitly proxied session. This local request checks the HTTP/CSV workflow, not a remote proxy. The URL check covers explicit local names and IP literals, not arbitrary DNS aliases or rebinding.

6. Adapt the selectors to an authorized market page
For a real collection job, replace the source and all instrument metadata with verified values. Set --price-selector, --volume-selector and --time-selector to selectors observed in that source’s returned HTML. The fixture defaults are not universal Yahoo, Bloomberg or TradingView selectors.

Run one approved HTTPS URL with --approved and a fresh output filename. That flag records your deliberate decision to run the request; it does not inspect a license or confer permission. The starter rejects query strings and embedded credentials to keep token-bearing URLs out of CSV exports. If your approved source needs query parameters, extend request construction and URL redaction deliberately before using it.
For example, a source you control might use #last-price, #volume-shares and time.quote-asof. Supply those exact selectors only if the page contains them. If the source exposes only a human-readable time such as “4:00 PM,” implement and test its date, timezone and daylight-saving rules before importing records. The starter instead fails on ambiguous timestamps.
Here is a complete parameter template, not a verified financial website. Replace the reserved .invalid address and every REPLACE_... value with source-verified information before running it:
python quote_scraper.py --url "https://quotes.example.invalid/quote/REPLACE_SYMBOL" --approved --symbol "REPLACE_SYMBOL" --exchange "REPLACE_EXCHANGE" --currency "REPLACE_CURRENCY" --market-session "REPLACE_SESSION" --delay-status "REPLACE_DELAY" --price-selector "REPLACE_PRICE_CSS" --volume-selector "REPLACE_VOLUME_CSS" --time-selector "REPLACE_TIME_CSS" --out authorized-direct.csv
The time selector must return an ISO timestamp with an offset, through text or an attribute supported by field. A real source needing different time/number formats requires an adapter before this command can work correctly.
If the fields exist only after JavaScript execution, use a documented data interface or permitted browser rendering. Beautiful Soup does not execute scripts. Do not treat a consent page, challenge screen or HTTP 200 login page as a valid quote response.
Which proxy setup fits a stock-price scraping workflow?
Choose an IP type and a session policy separately. A residential route need not rotate on every request, and a sticky session is not a permanently assigned address.
| Workflow requirement | Setup to evaluate | What to verify |
|---|---|---|
| A small permitted job with no routing requirement | Direct connection | Source request budget and valid fields |
| Allowlisting or long-lived exit continuity | Static residential/ISP or another genuinely fixed endpoint | Rental period, replacement policy and exit after reconnect |
| Several related requests in a short interval | Sticky residential session | Session expiry, idle timeout and peer availability |
| Independent, permitted localized requests | Location-targeted rotating residential endpoints | Observed exit and location; shared source-wide quota |
| A source that accepts hosting-network traffic | Datacenter proxy | Valid-record rate, latency and cost on that source |
For Socks5.IO residential routing, the session-control documentation distinguishes sticky and rotating modes. At this review, its standard sticky example describes a 10-minute session and reassignment after expiry or over 60 seconds idle; continuity also depends on the node remaining available. Confirm the generated configuration for your product rather than treating this example as a fixed-IP lease. Its rotating example uses -f-1 for per-request rotation; do not add or remove parameters without checking the selected mode.
Keep a stable exit for permitted multi-request comparisons that must share a session. Use rotation for independent requests only when consistent with the source’s conditions—not to reset a publisher’s quota or evade a refusal. For longer continuity, evaluate a fixed endpoint instead of repeatedly extending a sticky assumption.
Add Socks5.IO to an approved collection workflow
Socks5.IO’s rotating residential proxies are an option for approved collection or localized QA workflows that need residential routing and session controls. Apply the selection criteria above before choosing a product.
Use the account-and-password configuration guide to obtain your selected product’s issued host, port and proxy-user credentials. Keep generated location or session parameters exactly as documented. Do not invent a rotating-port range, substitute the website dashboard password, or alter a session parameter by guessing.

Configure authenticated SOCKS5 in Python
The complete script’s build_session reads issued endpoint fields from the environment and applies the proxy to both destination schemes. The excerpt below highlights this configuration; the complete program is in step 3.

The dictionary keys describe destination URL schemes; both can use the same SOCKS proxy. Requests documents the requests[socks] dependency and proxy-side DNS with socks5h. This HTTPS collection uses TCP; optional UDP features do not make it a faster market-data feed.
Set the issued values in your process environment or secret manager. For an interactive PowerShell session, these prompts keep the password out of the typed command history:
$env:SOCKS5IO_HOST = Read-Host 'Issued proxy host'
$env:SOCKS5IO_PORT = Read-Host 'Issued proxy port'
$env:SOCKS5IO_USER = Read-Host 'Issued proxy username'
$proxySecret = Read-Host 'Issued proxy password' -AsSecureString
$env:SOCKS5IO_PASSWORD = [System.Net.NetworkCredential]::new('', $proxySecret).Password
Environment variables are not an encrypted secret store. Use a dedicated process, do not print them, and remove them when finished. The script percent-encodes credentials when constructing the URL and suppresses raw network exception text that might contain it. Never publish a credential-bearing proxy URL in code, screenshots or logs.
After setting the issued environment fields, use this complete proxy template. Again, replace all placeholders first; .invalid is deliberately not a live target:
python quote_scraper.py --url "https://quotes.example.invalid/quote/REPLACE_SYMBOL" --approved --proxy --symbol "REPLACE_SYMBOL" --exchange "REPLACE_EXCHANGE" --currency "REPLACE_CURRENCY" --market-session "REPLACE_SESSION" --delay-status "REPLACE_DELAY" --price-selector "REPLACE_PRICE_CSS" --volume-selector "REPLACE_VOLUME_CSS" --time-selector "REPLACE_TIME_CSS" --out authorized-proxy.csv
Change only routing between the direct and proxy runs; keep the approved URL, metadata, selectors and collection window comparable. The script disables ambient proxy and .netrc settings, does not retry directly on a proxy failure, and keeps TLS verification enabled. Managed environments needing a custom CA must configure an approved trust bundle explicitly.
Verify routing separately from quote correctness
First compare direct and proxy requests from this Python client against a controlled endpoint you are authorized to use. Record the route, observation time and exit privately. A browser IP display or a standalone credential test does not prove this Python session used the proxy.
Then request the same approved quote URL using unchanged selectors and metadata. A successful routing check does not establish that the returned page contains a valid quote. Count a record as usable only after checking its instrument, fields, timestamp and source status.
| Observation | Next action |
|---|---|
| Proxy connection or authentication fails | Recheck the issued host, port, protocol, credentials and active service |
| Exit observed but expected fields are missing | Inspect source layout, response type and authorization state |
| Quote parses but is older than expected | Check the source’s timestamp, session and delay policy |
| Direct and proxy pages differ | Preserve time, request conditions and content differences before attributing a cause |
Measure usable records, not just successful requests
Keep a per-attempt record of HTTP classification, elapsed time, received content bytes, validation result and failure reason. Log source/instrument identity and session labels privately without passwords or full credential-bearing URLs. Compare direct and proxy routes under the same source-wide budget and near-identical request conditions.
- Valid-record rate = records passing structural, instrument, session and freshness checks ÷ attempted quote requests. The program performs structural checks; apply the other checks separately before counting a usable record.
- Request duration: measure the full fetch with a monotonic clock; report median and p95 together with failures and timeouts. Do not compare only the fastest successes.
- Traffic: record received content bytes, but do not equate these with billable bytes. Compression, overhead, failed requests and provider accounting can differ.
- Cost per valid record = attributable billed collection cost ÷ usable records. With zero usable records it is undefined, not zero. Label shared-cost allocation and use real billing, not a headline price alone.
Start with a small source-approved sample and no concurrency. Increase workers only within the agreed request budget. For broader trade-offs, see our guide to choosing proxies for scraping. No rate, latency or cost result is prefilled here.
Handle errors without corrupting your dataset
The starter makes one request, checks the response and stops on failure. Its Requests timeout settings are (5, 20) for connection and read operations, not a guaranteed total job deadline. It also stops on redirects, leaving you to review the destination before changing a collection URL.
| Failure | What to investigate | What not to do |
|---|---|---|
| HTTP 401 or 403 | Access conditions, credentials or source policy | Automatically cycle IPs to evade the refusal |
| HTTP 429 | Quota and Retry-After, if supplied | Immediately retry through another exit |
| Timeout or 5xx | Temporary availability; source-approved retry policy | Retry indefinitely or write partial quotes |
| HTTP 200 challenge/consent page | Response body and expected fields | Count status 200 as extraction success |
| Zero or multiple selector matches | Layout changes or ambiguous regular/after-hours fields | Take the first number anywhere on the page |
| Missing/ambiguous time or units | Source-specific parsing rules | Guess an exchange timezone or volume multiplier |
Production retries should be bounded, delayed and consistent with the source’s instructions. Apply a per-source request budget across all workers and exits. A larger proxy pool does not increase the quota a publisher grants your application.
Example business workflow: an end-of-day research dashboard
Consider a research team licensed to retain daily snapshots for 24 instruments. This is a design example, not a customer performance claim. The team records one approved close observation per instrument after its source’s expected publication time, validates currency and session, and sends missing or stale records to review.
Its collection target is 24 scheduled observations per run, not thousands of requests per minute. Historical split adjustments come from a documented historical source; yesterday’s HTML quote is not silently substituted. A fixed source URL, parser version and retrieval timestamp make it possible to investigate revisions later.
For an approved US-versus-UK page comparison, the team can generate two permitted location-targeted endpoints, assign private session labels and verify each observed exit from the Python client. Keep language, instrument, currency, cookies and collection times comparable. Use each sticky session only within its confirmed lifetime, and record a new observation after reconnect or expiry. A localized display difference is not an arbitrage opportunity by itself.
Acceptance still means correctly identified, timestamped records or logged failures—not 24 HTTP 200 responses. The team checks valid-record rate, time and attributable cost before choosing whether fixed, sticky or rotating routing is useful. A trading system needing licensed low-latency ticks should use an appropriate feed instead.
Conclusion
Web scraping stock prices starts with a permitted source and a precise data definition. Run the fictional HTML example, validate its CSV, test missing-field failures, and only then adapt a collector to an approved market page. Preserve source time, retrieval time, currency and session so a plausible number does not become misleading data.
If your authorized workflow needs a particular exit location or session behavior, choose the relevant Socks5.IO proxy type, use the product and authentication guidance above to generate an endpoint, and verify it from the same Python client. Run a small permitted collection test and compare usable records, latency and attributable cost before scaling. The next step is a measured configuration decision—not a promise of unblocked access or real-time trading data.











