When to Use
Use this skill when a strategy consumes scraped financial text for a ticker and needs a numeric, point-in-time sentiment feature out the other end. It covers the four places this pipeline usually breaks: the lexicon is not what it claims to be, syndicated copy is counted once per wire, the aggregation window silently includes documents published after the decision point, and a Z-score is reported against a standard deviation that was never measurable.
The engine:
- Cleans scraped text —
<script>/<style>bodies, tags, HTML entities, URLs (including barewww.), cashtags and punctuation — before tokenizing. - Scores against verified Loughran-McDonald word lists with a negation window, so "not profitable" scores negative.
- Reports both normalisations per document: the polarity $(N_p - N_n)/(N_p + N_n)$ and the
LM tone $(N_p - N_n)/N_{\text{tokens}}$, alongside
matched_word_countso a consumer can see how much evidence a $\pm1.0$ rests on. - Marks near-duplicate documents (same ticker, same cleaned text) rather than dropping them, so the batch stays auditable one record per input.
- Aggregates only documents inside
[signal_date - window + 1, signal_date]resolved insession_timezone, and counts everything it excluded. - Standardises against a caller-supplied baseline, or returns
INSUFFICIENT_DATAwithsentiment_zscore = None.
When NOT to Use
- As a substitute for the full dictionary. The bundled lists are a verified subset (80
positive, 140 negative) of a dictionary with 354 positive and 2,355 negative entries. They
exist so the module runs standalone. Load the real one with
load_lm_lexicon_from_master_dictionary()before trading it. - In a commercial deployment without checking the licence. Notre Dame's Software Repository
for Accounting and Finance publishes the LM materials "free for use in academic research" and
directs commercial users to contact the authors. Confirm entitlement first — see
alternative-data-vendor-due-diligence-checklist. - On filing text, with the default exclusions.
FILING_SPECIFIC_TERMSremoves LM entries whose polarity is an artefact of 10-K prose. On 10-K/8-K text that is the wrong call: passexclude_filing_specific_terms=False. - As a bot or coordination filter.
RawScrapedItemcarries no author or account metadata, so the engine cannot screen accounts, rate-limit bursts, or apply one-vote-per-author. Duplicate text collapse is all it does. For social feeds usesocial-media-sentiment-signal-with-bot-filtering. - As a standalone entry signal. The output is one alternative-data input. It carries no
position sizing, stops or exposure limits — see
liquidity-adjusted-position-sizingandkill-switch-and-drawdown-circuit-breakers. - Where the scraping itself is not permitted. Site terms of service,
robots.txt, and per-venue market-data entitlements govern what may be collected, stored and traded on, independently of what a page returns —data-vendor-contractual-usage-restriction-tracking.
Prerequisites
- Python 3.10+, standard library only (
csv,datetime,html,logging,math,re). RawScrapedItemrecords withitem_id,source,timestamp,ticker,text_content.- Timezone-aware timestamps. Naive datetimes are rejected at construction. A naive stamp cannot be placed on a trading day without guessing an offset, and guessing is how future documents leak into a backtest.
- A baseline in the right units.
historical_baseline_scoresmust be past values of this same daily aggregate for this ticker under the same filters and window — not individual document scores. The standard deviation of a mean of $n$ observations is smaller than the per-document standard deviation by roughly $\sqrt{n}$, so a per-document $\sigma$ in the denominator understates every $Z$ by that factor. The engine cannot detect which one it was handed. - A baseline matching
score_metric.polarity(default) andlm_toneare different units; a baseline built for one is invalid for the other. - Thresholds you are willing to defend:
zscore_threshold,min_matched_words,min_items,min_baseline_observations,aggregation_window_days,score_metric. The defaults are house heuristics, not standards — seereferences/standards.md.
Workflow
-
Ingest and validate: construct
RawScrapedIteminstances. A malformed item — naive timestamp, implausible ticker, non-string text — raises rather than being dropped, so a broken ingestion join fails loudly instead of shrinking the sample in silence. -
Clean:
clean_text(text).- Decision point — remove
<script>/<style>bodies before stripping tags. Stripping only the tags leaves the JavaScript in the corpus, where identifiers and string literals tokenize into the lexicon. - Decision point — unescape entities to a fixed point, before removing punctuation.
Otherwise
&loses its∧and leaves the tokenampin every scraped article — and a double-escaped page (&amp;, common when a CMS escapes content that was already escaped) defeats a single unescape pass. - Decision point — an unclosed
<script>still hides its body. A truncated scrape ends mid-element; stripping only the opening tag spills the JavaScript into the tokens.
- Decision point — remove
-
Score:
score_text(clean)returns(positive, negative, polarity).- Decision point — a lexicon term within
NEGATION_WINDOW(3) tokens after a negator has its polarity flipped. Without it, "not profitable" and "highly profitable" score identically at $+1.0$. - Decision point — polarity is not the LM tone measure. Loughran and McDonald normalise
word-list counts by the total words in the document. Polarity divides by matched words
only, which makes a one-word match indistinguishable from a fifty-word match. On short news
text most documents saturate at exactly $\pm 1.0$, so the daily mean over polarity is close
to binary. Set
score_metric="lm_tone"to aggregate the LM normalisation instead — and rebuild the baseline, because the two are different units.
- Decision point — a lexicon term within
-
Process the feed:
process_scraped_feed(items).- Decision point — duplicates are marked, not deleted. A press release reposted across
PR Newswire, Business Wire and a dozen aggregators is one opinion.
is_duplicateandduplicate_ofpreserve the lineage; the signal step excludes them from the aggregate. - Duplicate identity is
(ticker, cleaned text), so the same headline about two different tickers is two documents, and empty documents are never collapsed into one group. - Decision point — the surviving copy is the earliest by timestamp, not the first in the list. The survivor's timestamp decides which day the document lands on, so keeping a later repost can push a document across the point-in-time cutoff. Deduplication is therefore independent of the order the caller supplied.
- Decision point — duplicates are marked, not deleted. A press release reposted across
PR Newswire, Business Wire and a dozen aggregators is one opinion.
-
Generate the signal:
generate_ticker_signals(scored, ticker, signal_date, baseline).- Decision point —
signal_dateis a cutoff, not a label. Documents stamped at or after midnight followingsignal_dateinsession_timezoneare excluded and counted infuture_items_excluded. A backtest that passes the full scored history is otherwise averaging in documents published after the decision it is simulating. - Decision point — below
min_itemseligible documents, or belowmin_baseline_observations, there is no Z-score at all.sentiment_zscoreisNoneand the direction isINSUFFICIENT_DATA. - Decision point —
INSUFFICIENT_DATAis notNEUTRAL.NEUTRALmeans the balance of opinion was measured and was flat. A consumer may reasonably act on the second and must never act on the first. - Decision point — a degenerate baseline raises the flag, not a substitute $\sigma$. A
constant baseline has $\sigma = 0$ and no Z-score interpretation. Substituting $\sigma = 1.0$
fabricates the denominator and reports a confident number built on nothing. The test is
$\sigma \ge$
min_baseline_std, not $\sigma > 0$: a $\sigma$ of $10^{-160}$ passes a bare positivity check and then yields $|Z| \approx 10^{160}$ — reported as maximum conviction next to abaseline_stdthat displays as0.0. - Decision point — the band is decided on the unrounded $Z$. Rounding first promotes
$1.4951$ to $1.50$ and hands back a
LONGthe data does not support.
- Decision point —
Full procedure: see
references/workflows.md. Standards, formulas and threshold provenance: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- Calling a hand-written word list "Loughran-McDonald": LM is a specific, published,
verifiable artefact.
growth,revenue,dividend,buy,record,momentum,bullish,rally,surge,slump,bearishandsellare not in either LM sentiment category. Attributing them to LM misrepresents the dictionary and the paper. Check membership against the Master Dictionary rather than trusting the label. - Reading
riskas negative sentiment: LM classifies it under Uncertainty, not Negative.lawsuitis Litigious anddropis Interesting. Folding the other categories into the negative count changes what the score measures and breaks comparability with any published result. - Transferring 10-K vocabulary to headlines unexamined: LM was induced from filings, so
despiteis LM-Positive andclosed,disclose,claims,critical,force,against,volatilityandrestructuringare LM-Negative. "Shares closed higher" is not a negative headline. SeeFILING_SPECIFIC_TERMS. - A one-word match reported as maximum conviction: the polarity $(N_p - N_n)/(N_p + N_n)$
saturates at exactly $\pm 1.0$ on a single matched word.
min_matched_wordsexists for this; do not set it to 1 to make a thin ticker "work". - Switching
score_metricwithout rebuilding the baseline:polarityandlm_tonehave different scales. Standardising a tone mean against a polarity baseline produces a Z-score that is arithmetically valid and semantically meaningless. - Trading a mean over one document: one document at $+1.0$ against a $\sigma$ of $0.125$ is $Z = +8$ — a maximum-conviction signal off a sample of one.
- Syndicated double-counting: the same press release across twelve wires inflates both the mean and the apparent sample size. Text-identity dedup catches verbatim reposts; a rewritten lede needs MinHash or shingled Jaccard, which this engine does not implement.
signal_dateused as a label rather than a cutoff: the single most expensive bug in this pipeline. A signal whose exclusion counters all read zero is not evidence of correctness — it may just mean no cutoff was ever applied.- Timezone-naive timestamps: a document stamped
2026-03-11T00:00:00Zis inside the 10 March New York session and outside the 10 March UTC session. Which day a document lands on decides whether it is data or look-ahead. - Absorbing a broken baseline into $Z = 0$: setting
baseline_mean = current_meanwhen no baseline was supplied produces $Z = 0$ and a confidentNEUTRALon zero information. - Reading
confidence_scoreas a probability: it is $\min(1, |Z| / (\text{threshold} \times \text{multiple}))$ — a bounded presentation scale, not a calibrated likelihood of the trade working. - Assuming the engine screens bots: it does not. It has no account metadata to screen with.
Verification
- Construct
WebScrapedSentimentPipelineEngine(). Confirm the bundled lists carry 80 positive and 140 negative terms, are lowercase, and are disjoint; confirmgrowth,revenue,dividend,buy,record,bullish,rally,surge,slump,bearish,sell,risk,lawsuitanddropare in neither. - Cleaning:
<script>var loss = 'failure failure';</script>contributes no tokens;profit & lossyieldsprofit losswith noamp;www.spam.example/xis removed;record_losssplits into two tokens; a non-string raises. - Scoring:
strong improvement profitable$= (3, 0, +1.0)$;strong improvement profitable loss$= (3, 1, +0.5)$;not profitable$= (0, 1, -1.0)$; a term four tokens after the negator is not flipped. - Intensity:
strong improvementand the same two words buried in 98 filler tokens both score polarity $+1.0$; onlylm_toneseparates them ($1.0$ versus $0.02$). Withscore_metric="lm_tone"the same three long documents that give a polarity mean of $1.0$ give a tone mean below $0.2$ and a correspondingly smaller $Z$. - Dedup: three wire copies of one release mark two as
is_duplicatewithduplicate_ofset to the first; the same text under two tickers marks neither. - Point-in-time: with
session_timezone=UTC, three documents stamped2026-03-11T00:00Zagainstsignal_date=2026-03-10land infuture_items_excluded; the same documents under a UTC-4 session are retained.2026-03-10T23:59:59Zis included. - Look-ahead regression: three positive documents on the signal date plus twenty negative
documents stamped the next day must yield
LONGwithfuture_items_excluded == 20, not theSHORTan uncut aggregate produces. - Gates: one eligible document, four documents matching a single lexicon word, five wire copies
of one release, a 5-observation baseline, and a constant baseline
[0.25] * 21must each yieldINSUFFICIENT_DATAwithsentiment_zscore is Noneandbaseline_std is None. - Banding: with a baseline of mean $-0.1875$ and $\sigma = 0.125$ and a document mean of $0.0$,
$Z = 1.5$ exactly must band
LONG(inclusive). With mean $-0.1868875$, $Z = 1.4951$ must reportsentiment_zscore == 1.5and still bandNEUTRAL. - Negative checks: a NaN, infinite, string or boolean baseline value; a
datetimepassed assignal_date; a blanktarget_ticker; a naive timestamp;zscore_thresholdof 0, negative or NaN;min_baseline_observations=1; an unrecognisedscore_metric; an empty or overlapping word list — all raiseSentimentPipelineError(aValueErrorsubclass). - Run
python -m unittest discover -s skills/web-scraped-sentiment-data-pipeline/scriptsand confirm a 100% pass rate.
Related Skills
social-media-sentiment-signal-with-bot-filteringearnings-call-transcript-nlp-signal-researchgoogle-trends-and-search-volume-signal-researchalternative-data-vendor-due-diligence-checklistdata-vendor-contractual-usage-restriction-trackinglookahead-bias-eliminationfeature-engineering-without-leakagewash-trade-and-spoofing-self-detection