stocks / scripts /extract_trades.py
3v324v23's picture
Innitial Commmit
a99e3b0
Raw
History Blame Contribute Delete
10.4 kB
#!/usr/bin/env python3
"""
Extract Timothy Sykes trades from profit.ly
Two-phase approach:
Phase 1: Collect all page URLs
Phase 2: Extract trades from each page
Saves to CSV with resume capability
"""
import argparse
import csv
import logging
import os
import random
import re
import time
from dataclasses import dataclass
import requests
from bs4 import BeautifulSoup
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Constants
BASE_URL = "https://profit.ly/user/timothysykes/trades"
CSV_FILE = "data/trades.csv"
PAGE_SIZE = 25
MAX_RETRIES = 3
INITIAL_RETRY_DELAY = 5.0
# Rate limiting (randomized between min/max)
MIN_DELAY = 2.0
MAX_DELAY = 5.0
CSV_FIELDS = ["profit", "ticker", "date", "username", "entry_comments", "exit_comments"]
HEADERS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
]
@dataclass
class Trade:
"""Represents a single trade."""
profit: str
ticker: str
date: str
username: str
entry_comments: str
exit_comments: str
def to_dict(self) -> dict[str, str]:
return {
"profit": self.profit,
"ticker": self.ticker,
"date": self.date,
"username": self.username,
"entry_comments": self.entry_comments,
"exit_comments": self.exit_comments,
}
def get_total_pages(html: str) -> int | None:
"""Extract total pages from pagination control."""
soup = BeautifulSoup(html, "html.parser")
input_elem = soup.find("input", {"id": "paginationInput"})
if input_elem:
max_val = input_elem.get("max")
if max_val is not None:
try:
return int(str(max_val))
except (ValueError, TypeError):
pass
return None
def extract_trades(html: str) -> list[Trade]:
"""Extract all trades from HTML content using BeautifulSoup."""
trades = []
soup = BeautifulSoup(html, "html.parser")
# Find all trade feed bodies
feed_bodies = soup.find_all("section", class_="trade-feed-body")
for feed in feed_bodies:
# Extract profit from next card-header (it comes AFTER the feed body in HTML)
card_header = feed.find_next("div", class_="card-header")
if not card_header:
continue
profit_elem = card_header.find("a", class_=["trade-up", "trade-down"])
ticker_elem = card_header.find("a", class_="trade-ticker")
if not profit_elem or not ticker_elem:
continue
# Extract ticker and type
ticker = ticker_elem.get_text(strip=True)
profit = profit_elem.get_text(strip=True)
# Extract date
date_elem = feed.find("date")
date = date_elem.get_text(strip=True) if date_elem else ""
# Extract username
username_elem = feed.find("a", class_="name")
username = username_elem.get_text(strip=True) if username_elem else ""
# Extract comments
wall_texts = feed.find_all("p", class_="wall-text")
entry = ""
exit_comments = ""
if wall_texts:
entry_text = wall_texts[0].get_text(strip=True)
entry = re.sub(r"^Entry comments:\s*", "", entry_text).strip()
if len(wall_texts) >= 2:
exit_text = wall_texts[1].get_text(strip=True)
exit_comments = re.sub(r"^Exit comments:\s*", "", exit_text).strip()
trades.append(
Trade(
profit=profit,
ticker=ticker,
date=date,
username=username,
entry_comments=entry,
exit_comments=exit_comments,
)
)
return trades
def write_csv_header() -> None:
"""Write CSV header if file doesn't exist."""
os.makedirs(os.path.dirname(CSV_FILE), exist_ok=True)
if not os.path.exists(CSV_FILE):
with open(CSV_FILE, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=CSV_FIELDS)
writer.writeheader()
# Global set to track seen trades (profit+ticker+date)
_seen_trades: set[tuple[str, str, str]] = set()
def load_existing_trades() -> None:
"""Load existing trades from CSV into the seen set to avoid duplicates."""
if os.path.exists(CSV_FILE):
with open(CSV_FILE, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
_seen_trades.add((row["profit"], row["ticker"], row["date"]))
def append_trades(trades: list[Trade]) -> int:
"""Append trades to CSV file, skipping duplicates. Returns count appended."""
count = 0
with open(CSV_FILE, "a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=CSV_FIELDS)
for trade in trades:
key = (trade.profit, trade.ticker, trade.date)
if key not in _seen_trades:
_seen_trades.add(key)
writer.writerow(trade.to_dict())
count += 1
return count
def fetch_page(session: requests.Session, page: int) -> str:
"""Fetch a single page with retry logic."""
url = f"{BASE_URL}?page={page}&size={PAGE_SIZE}"
ua = random.choice(HEADERS)
session.headers.update({"User-Agent": ua})
for attempt in range(MAX_RETRIES):
try:
response = session.get(url, timeout=30)
response.raise_for_status()
return response.text
except requests.HTTPError as e:
if e.response.status_code == 429:
delay = min(INITIAL_RETRY_DELAY * (2**attempt), 60)
logger.warning(f"Rate limited (429), waiting {delay}s...")
time.sleep(delay)
elif e.response.status_code >= 500:
delay = INITIAL_RETRY_DELAY * (2**attempt)
logger.warning(f"Server error {e.response.status_code}, retry {attempt + 1}/{MAX_RETRIES}")
time.sleep(delay)
else:
raise
except requests.RequestException as e:
delay = INITIAL_RETRY_DELAY * (2**attempt)
logger.warning(f"Request failed, retry {attempt + 1}/{MAX_RETRIES}: {e}")
time.sleep(delay)
raise RuntimeError(f"Failed to fetch page {page} after {MAX_RETRIES} attempts")
def phase1_collect_urls(output_dir: str = "data", max_pages: int | None = None) -> None:
"""Phase 1: Download raw HTML files for all pages."""
session = requests.Session()
# Fetch first page to get total pages
logger.info("Phase 1: Discovering total pages...")
html = fetch_page(session, 1)
total_pages = get_total_pages(html)
if not total_pages:
logger.error("Could not determine total pages, using fallback detection")
total_pages = 0
# Limit pages if specified (for testing)
if max_pages is not None:
total_pages = min(total_pages, max_pages)
logger.info(f"Total pages to fetch: {total_pages}")
# Create output directory
os.makedirs(output_dir, exist_ok=True)
# Save first page HTML
with open(os.path.join(output_dir, "page_1.html"), "w", encoding="utf-8") as f:
f.write(html)
logger.info("Saved page 1")
# Fetch remaining pages
for page in range(2, total_pages + 1):
try:
html = fetch_page(session, page)
output_file = os.path.join(output_dir, f"page_{page}.html")
with open(output_file, "w", encoding="utf-8") as f:
f.write(html)
logger.info(f"Saved page {page}/{total_pages}")
# Rate limiting
delay = random.uniform(MIN_DELAY, MAX_DELAY)
time.sleep(delay)
except KeyboardInterrupt:
logger.info(f"Interrupted at page {page}")
break
except Exception as e:
logger.error(f"Error on page {page}: {e}")
time.sleep(10)
logger.info(f"Phase 1 complete: {total_pages} HTML files saved to {output_dir}")
def phase2_extract_trades(input_dir: str = "data") -> None:
"""Phase 2: Read saved HTML files and extract trades to CSV."""
# Initialize CSV
write_csv_header()
load_existing_trades()
# Find all HTML files
html_files = sorted(
[f for f in os.listdir(input_dir) if f.startswith("page_") and f.endswith(".html")],
key=lambda x: int(x.split("_")[1].split(".")[0]),
)
if not html_files:
logger.error(f"No HTML files found in {input_dir}. Run phase 1 first.")
return
logger.info(f"Phase 2: Processing {len(html_files)} HTML files from {input_dir}")
processed_count = 0
for html_file in html_files:
try:
file_path = os.path.join(input_dir, html_file)
with open(file_path, encoding="utf-8") as f:
html = f.read()
trades = extract_trades(html)
if trades:
page_num = html_file.replace("page_", "").replace(".html", "")
count = append_trades(trades)
logger.info(f"Page {page_num}: {count} new trades")
processed_count += 1
except KeyboardInterrupt:
logger.info(f"Interrupted at {html_file}")
break
except Exception as e:
logger.error(f"Error on {html_file}: {e}")
logger.info(f"Phase 2 complete: {processed_count} pages processed")
def main() -> None:
parser = argparse.ArgumentParser(description="Extract Timothy Sykes trades")
parser.add_argument(
"phase",
choices=["1", "2", "both"],
default="both",
nargs="?",
help="Phase to run: 1 (collect URLs), 2 (extract trades), both (default)",
)
parser.add_argument(
"--max-pages",
type=int,
default=None,
help="Maximum pages to fetch in phase 1 (for testing)",
)
args = parser.parse_args()
if args.phase in ("1", "both"):
phase1_collect_urls(max_pages=args.max_pages)
if args.phase in ("2", "both"):
phase2_extract_trades()
if __name__ == "__main__":
main()