Part of the Sanchayam series. Expands Stage 2 of the corporate actions pipeline.
What
Sanchayam computes FIFO cost basis and XIRR from historical prices. Prices are fetched by trading symbol. When a company changes its symbol (INFOSYSTCH to INFY) or its name (Hindustan Lever to Hindustan Unilever), a price lookup by the current symbol silently misses every week before the change.
The backfill pipeline fixes this with an alias table. One row per symbol-and-name period, so each historical date range is queried with the symbol that was live at the time. Yahoo Finance supplies splits and bonuses. NSE corporate announcements supply name changes, symbol changes, and mergers.
NSE announcements are the hard source. They are free text with no structure, inconsistent phrasing, and no endpoint that returns “company X changed its name to Y on date Z”. This post covers how Sanchayam extracts that data with DeepSeek. The code lives in sanchayamBackend-public.
Why Not Regex
The first version of the extractor was pure regex. It still sits in the codebase. It looks for “effective from”, “w.e.f.”, and phrases shaped like “changed from X to Y”, and it knows three date formats. It handles the cleanest announcements and misses the rest.
Two problems are structural, not fixable with more patterns:
Dates. The same event appears as 16-Jun-2011, 2011-06-16, and June 16, 2011 across announcements. A pattern that matches one format misses the other two, and a pattern broad enough for all three starts matching unrelated numbers.
Judgment. HDFC Ltd merging into HDFC Bank changes the listed entity’s history. A subsidiary merging into another subsidiary does not. The announcement text rarely says which one it is directly. A regex can only pattern-match text. It cannot decide what matters.
That second problem decided it. Extraction from this feed is a comprehension problem, so the extractor is an LLM.
The NSE Feed
Sanchayam fetches the announcement feed directly:
const NSE_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://www.nseindia.com/',
'Accept': 'application/json',
}
const url = `https://www.nseindia.com/api/corporate-announcements?index=equities&symbol=${encodeURIComponent(symbol)}`
NSE blocks requests without browser-like headers, so the 403s stop only when the User-Agent and Referer look like a real browser.
One symbol returns hundreds of announcements: quarterly results, board meetings, dividends, shareholding disclosures. Three categories matter for corporate actions:
const relevant = announcements.filter(a =>
a.desc === 'Company Name Change' ||
a.desc === 'Change in Company Name / Symbol' ||
a.desc === 'Amalgamation/Merger'
)
If the filter comes back empty, the DeepSeek call is skipped entirely. Most assets never trigger an LLM call.
What We Send
DeepSeek receives only the relevant announcements, flattened to three fields:
[
{
"sort_date": "2021-06-14",
"desc": "Company Name Change",
"text": "The name of the Company has been changed from XYZ Limited to ABC Limited w.e.f. 15-Jun-2021. The change is effective from the said date."
}
]
No HTML, no full feed, no pre-processing beyond the filter. The call itself is the standard chat completions endpoint with model: 'deepseek-chat', temperature: 0 (extraction is deterministic work, not creative writing), and max_tokens: 2000.
The Prompt
The system prompt is where all the domain rules live:
const SYSTEM_PROMPT = `You are a financial data extraction engine. You receive a list of NSE (National Stock Exchange India) corporate announcement texts for a single listed company.
Extract all of the following if present:
1. Company name changes - old name, new name, effective date
2. Trading symbol changes - old symbol, new symbol, effective date
3. Mergers/amalgamations - but ONLY ones that are material to the listed entity itself (i.e. another company merged INTO this company, or this company merged INTO another company). Ignore internal subsidiary/step-down restructuring that does not affect the listed entity directly.
For mergers:
- direction "merged_into_us" = another entity was absorbed into this listed company (e.g. HDFC Ltd merged into HDFC Bank)
- direction "we_merged_into" = this listed company was absorbed into another entity (delisting scenario)
- is_material = true only if the listed entity itself is a party to the merger, not just its subsidiaries
- effective_date = the date the merger actually became effective (not announcement date, not NCLT order date). Use the sort_date of the announcement that says the merger is "completed" or "effective". If no explicit effective date exists in the text, use the sort_date of the latest announcement.
Return ONLY a JSON object. No explanation. No markdown. No code fences. Just raw JSON matching this schema exactly:
{
"name_changes": [{"old_name":"","new_name":"","effective_date":"YYYY-MM-DD"}],
"symbol_changes": [{"old_symbol":"","new_symbol":"","effective_date":"YYYY-MM-DD"}],
"mergers": [{"effective_date":"YYYY-MM-DD","counterparty":"","direction":"merged_into_us|we_merged_into","is_material":true,"notes":""}]
}`
Each rule exists because a looser prompt gets something wrong.
Materiality. Without the rule, the model returns subsidiary restructuring as mergers - technically correct readings of the text that pollute the alias table. The prompt names the HDFC Ltd into HDFC Bank example so the model has a concrete reference for what material means.
Effective date. Announcement date and NCLT order date both precede the actual effective date, sometimes by months. The prompt forces the model to find the announcement that says “completed” or “effective” and use that date, with the latest sort_date as fallback.
Output contract. “Only JSON. No markdown. No fences.” A chat model’s default is to wrap the answer in json fences and add a sentence of explanation. That breaks JSON.parse. The contract is repeated because it is the most violated rule.
The Response Schema
The response type mirrors the prompt schema:
export type DeepSeekExtracted = {
name_changes: Array<{ old_name: string; new_name: string; effective_date: string }>
symbol_changes: Array<{ old_symbol: string; new_symbol: string; effective_date: string }>
mergers: Array<{
effective_date: string
counterparty: string
direction: 'merged_into_us' | 'we_merged_into'
is_material: boolean
notes: string
}>
}
The parse is deliberately defensive. If the content is not JSON, the call throws with the first 200 characters of the response so the failure is debuggable from logs. If any array is missing, it falls back to an empty array rather than crashing the pipeline:
return {
name_changes: Array.isArray(parsed.name_changes) ? parsed.name_changes : [],
symbol_changes: Array.isArray(parsed.symbol_changes) ? parsed.symbol_changes : [],
mergers: Array.isArray(parsed.mergers) ? parsed.mergers : [],
}
Validation: Trust Nothing
Model output is unvalidated input to the database. Sanchayam runs every extracted record through a validator before anything is stored:
- Dates must be valid ISO strings, not in the future, not before 1900
- Symbols must match
[A-Z0-9&.-] - A bonus ratio must increase the share count (a bonus can never decrease it)
- A 1:1 split is a no-op and gets dropped
A record that fails validation is dropped, not the whole batch. The valid records continue, and the admin gets a CORPORATE_ACTION_VALIDATION_FAILED notification with the field, the value, and the reason. One hallucinated record does not block an asset’s backfill.
Validation catches malformed data, not wrong data. A syntactically valid but incorrect effective date passes. The alias table is admin-editable, and bad extractions are corrected there by hand.
From JSON to Alias Rows
A name change becomes two alias rows:
aliases.push({ symbol, name: nc.old_name, from_date: '2000-01-01', to_date: nc.effective_date })
aliases.push({ symbol, name: nc.new_name, from_date: nc.effective_date, to_date: null })
The old name covers everything up to the effective date. The new name covers everything after it (to_date: null means current). The 2000-01-01 floor is an arbitrary unknown-start placeholder; a later backfill run overwrites it.
Symbol changes produce the same shape with an empty name, which the validator accepts and the system fills from name-change records later. Mergers skip the alias table entirely: only material ones become corporate_actions rows, with notes like “XYZ Limited merged into ABC Limited”.
From there the rows feed Stage 3 of the backfill pipeline: the price worker checks the alias table per date range and fetches each period with the symbol that was live then.
Limits
Three honest ones:
Pre-2003 history. The NSE announcement feed does not cover events before roughly 2003. If an asset has lots older than that, Sanchayam emits a CORPORATE_ACTION_PRE2003_GAP notification and the admin verifies that history manually.
Shape, not truth. The validator catches wrong formats, not wrong facts. The model can return a confident, well-formed, incorrect date, and nothing in the pipeline detects it. The manual correction path on the alias table exists for this reason.
One provider. The NSE stage is a single call to a single API. If DeepSeek is down, the stage throws and the backfill retries later. There is no fallback extractor today; the retry is the resilience mechanism.
The extraction runs once per asset when the asset is added, not on every price fetch. The common case - no relevant announcements - costs nothing.