VPS पर अपना Stock Research Agent कैसे बनाएं
Python, DuckDB और systemd timer का उपयोग करके अपना खुद का स्टॉक रिसर्च एजेंट सेटअप करें। यह गाइड मार्केट डेटा फीड, ऑटोमेटेड डेटा स्टोर और LLM आधारित एनालिसिस के चरण बताती है।
Self-hosted stock research agent क्या है
Self-hosted stock research agent आपके अपने सर्वर पर चलने वाला एक छोटा प्रोग्राम है। यह निर्धारित समय पर मार्केट डेटा प्राप्त करता है, उसे एक local database में सुरक्षित रखता है, उस पर एक screen चलाता है और एक large language model (LLM) से बदलावों का विवरण तैयार करवाता है। यह डेटा पढ़ता है और उसे filter करता है। यह ट्रेडिंग नहीं करता है और इस गाइड में दी गई कोई भी जानकारी वित्तीय सलाह (financial advice) नहीं है।
यदि दो लोग इसे शून्य से बनाना शुरू करें, तो वे अलग-अलग libraries चुन सकते हैं, लेकिन अंत में वे चार समान हिस्से ही बनाएंगे: एक feed जो कीमतें और fundamentals प्रदान करती है, एक local store जो आपके द्वारा प्राप्त की गई हर row को सुरक्षित रखता है, एक job जो timer पर store को refresh करती है, और एक LLM layer जो बची हुई rows को वाक्यों में बदलती है। यह गाइड Python, DuckDB, systemd timer और Claude API (application programming interface) का उपयोग करके इस ढांचे को तैयार करती है। Execution एक अलग job है जिसके failure modes अलग होते हैं, और इसे trading bots के लिए सेटअप किए गए VPS पर ही रखा जाना चाहिए।
चार भाग और प्रत्येक का कार्य
The feed एकमात्र ऐसा भाग है जो बाहरी दुनिया से संपर्क करता है। इसे यह पता होता है कि ticker और date range के लिए अनुरोध कैसे करना है, और पंक्तियों (rows) को वापस कैसे सौंपना है। downstream में मौजूद हर चीज़ feed के बजाय आपके database को पढ़ती है, इसलिए feed के बंद होने पर आपको केवल एक दिन का नया डेटा खोना पड़ता है, न कि पूरी स्क्रीन का खराब होना।
The store इस पूरी प्रक्रिया का मुख्य उद्देश्य है। यदि आपने किसी दिन के close को record नहीं किया है, तो उसे आमतौर पर बाद में फिर से प्राप्त किया जा सकता है। लेकिन intraday quote, संशोधित होने से पहले का estimate, या restate होने से पहले का fundamentals figure दोबारा प्राप्त नहीं किया जा सकता। The store के माध्यम से ही आप इस बात का record तैयार करते हैं कि डेटा ने वास्तव में उस दिन क्या कहा था।
The scheduler यह तय करता है कि refresh कब होगा। सर्वर पर यह एक systemd timer होता है, और यही कारण है कि यहाँ VPS का महत्व code से अधिक है।
The LLM layer SQL द्वारा तैयार किए गए एक छोटे text block को पढ़ता है और उसका सारांश लिखता है। यह कभी भी database से connect नहीं होता और न ही query बनाता है। यदि model ही SQL लिखे, तो एक गलत token एक धाराप्रवाह वाक्य के भीतर गलत संख्या बन सकता है, जिसकी तुलना करने के लिए कुछ भी नहीं होगा। यदि SQL संख्याएँ उत्पन्न करता है, तो model केवल prose (गद्य) में गलती कर सकता है, और आप उस prose की जाँच उन पंक्तियों (rows) से कर सकते हैं जिन्हें आपने भेजा था।
लैपटॉप के बजाय VPS पर इसे क्यों चलाएं
इसका कारण scheduler है। न्यूयॉर्क के समयानुसार 16:00 बजे US मार्केट बंद होने का समय बर्लिन में 22:00 बजे और जकार्ता में अगली सुबह 04:00 बजे होता है। इन दोनों समय पर लैपटॉप स्लीप मोड में होता है। एक छूटा हुआ रन देर से मिलने वाले नोट से अधिक महंगा पड़ता है: daily bars को आमतौर पर बाद में फिर से प्राप्त किया जा सकता है, लेकिन जो कुछ भी संशोधित (revised) हो जाता है उसे दोबारा प्राप्त नहीं किया जा सकता, इसलिए आपके रिकॉर्ड में आया गैप स्थायी होता है। यही तर्क किसी भी ऐसे agent पर लागू होता है जिसका schedule और stored state रीबूट के बाद भी सुरक्षित रहना चाहिए, और यही वह आधार है जिसके इर्द-गिर्द KiroCrew को अपने VPS पर हमेशा चालू रहने वाले agent के रूप में चलाना बनाया गया है।
दूसरा कारण छोटा है लेकिन फिर भी वास्तविक है। सर्वर एक API key को एक फाइल में रखता है, जिसका स्वामित्व एक ऐसे system user के पास होता है जिसका कोई login shell नहीं है, और जिसे केवल एक ही job द्वारा उपयोग किया जाता है। लैपटॉप पर ऐसी व्यवस्था करना बहुत कठिन है, जिसका उपयोग आप वेब ब्राउज़ करने के लिए भी करते हैं। key को कोड से और model के इनपुट से बाहर रखें, जो कि AI agent से API keys को सुरक्षित रखने का विषय है।
आकार निर्धारित करना: disk, RAM और tokens
Disk का हिस्सा आसान है। एक दैनिक बार का मतलब है प्रति ticker प्रति trading day एक row, और एक US trading वर्ष में लगभग 252 दिन होते हैं।
The data behind this chart
[
{
"label": "20 tickers",
"rows_after_1y": "5,040",
"rows_after_10y": "50,400"
},
{
"label": "100 tickers",
"rows_after_1y": "25,200",
"rows_after_10y": "252,000"
},
{
"label": "500 tickers",
"rows_after_1y": "126,000",
"rows_after_10y": "1,260,000"
}
]बीस tickers का मतलब एक वर्ष के बाद 5,040 rows है। 500 tickers लाइन दस वर्षों के बाद 1,260,000 rows तक पहुँच जाती है। प्रत्येक row में एक date और कुछ doubles होते हैं, और DuckDB columns को compress करके store करता है, इसलिए यह gigabytes के बजाय केवल कुछ tens of megabytes का डेटा है। इस अनुमान पर भरोसा न करें, मेरे अनुमान पर भी नहीं। अपने पहले backfill के बाद du -h /opt/research/data/market.duckdb चलाएँ और अपनी खुद की संख्या का उपयोग करें।
RAM वह जगह है जहाँ एक छोटा VPS समस्या पैदा करता है। DuckDB डिफ़ॉल्ट रूप से एक ही query के लिए मशीन की memory का एक बड़ा हिस्सा और उसके सभी cores का उपयोग करता है, जो एक analytics server के लिए तो ठीक है, लेकिन 2 GB वाले उस box के लिए गलत है जिस पर अन्य चीजें भी चल रही हों। पूरी prices table पर एक aggregation चलाने से process को kernel द्वारा kill कर दिया जाता है, और systemd Main process exited, code=killed, status=9/KILL रिपोर्ट करता है जबकि journalctl -k out of memory kill को दिखाता है। memory_limit और threads को स्पष्ट रूप से सेट करें, इससे query मर जाने के बजाय धीमी हो जाएगी।
Tokens को मापा जाना चाहिए, अनुमान नहीं लगाया जाना चाहिए। हर Messages API response में input_tokens और output_tokens के साथ एक usage object होता है। हर call पर दोनों को एक table में लिखें, और एक सप्ताह के बाद आपको अपनी वास्तविक volume पता चल जाएगी, जिसे आप उस दिन के model के मूल्य से गुणा कर सकते हैं जिस दिन आप जाँच करते हैं। दो चीजें योजना बनाने के लिए पर्याप्त स्थिर हैं। हर Claude model पर output tokens की कीमत input tokens से अधिक होती है, इसलिए note को 200 शब्दों तक सीमित रखना आपके द्वारा भेजे जाने वाले डेटा को कम करने की तुलना में बिल पर अधिक प्रभाव डालता है। और prompt caching दिन में एक बार चलने वाले job में मदद नहीं करती है, क्योंकि cache का lifetime मिनटों में मापा जाता है: अगली बार चलने तक cached block expire हो चुका होता है और आप फिर से पूरा input मूल्य चुकाते हैं। Caching तब फायदेमंद होती है जब एक ही run में एक ही बड़े text block पर कई calls की जाती हैं।
घटकों को इंस्टॉल करें
sudo apt update
sudo apt install -y python3-venv
sudo useradd --system --create-home --home-dir /opt/research --shell /usr/sbin/nologin research
sudo -u research python3 -m venv /opt/research/venv
sudo -u research /opt/research/venv/bin/pip install duckdb pandas yfinance anthropic
sudo install -d -o research -g research -m 750 /opt/research/dataकोई भी कोड लिखने से पहले इंस्टॉलेशन की जाँच करें:
sudo -u research /opt/research/venv/bin/python -c 'import duckdb, yfinance, anthropic; print("ok")'यह ok प्रिंट करता है। यदि आपने virtual environment को छोड़ दिया और सिस्टम Python पर pip install चलाया, तो Ubuntu 24.04 आपको error: externally-managed-environment के साथ रोक देगा, क्योंकि डिस्ट्रीब्यूशन का /usr/lib/python3 पर स्वामित्व है और वह pip को वहाँ लिखने की अनुमति नहीं देता है। Venv केवल शिष्टाचार नहीं है। यह एकमात्र ऐसी निर्देशिका है जिसे pip छू सकता है।
API key को ऐसी फ़ाइल में रखें जिसे केवल service user पढ़ सके और कोई अन्य नहीं:
sudo install -d -m 755 /etc/research
sudo install -m 640 -o root -g research /dev/null /etc/research/env
sudoedit /etc/research/envइसमें एक पंक्ति लिखें, बिना किसी उद्धरण चिह्न (quotes) और बिना export के, क्योंकि systemd इस फ़ाइल को शेल में भेजने के बजाय स्वयं पार्स करता है:
ANTHROPIC_API_KEY=sk-ant-your-key-hereस्टोर: दो टेबल
# /opt/research/store.py
import duckdb
DB = '/opt/research/data/market.duckdb'
SCHEMA = [
"""
CREATE TABLE IF NOT EXISTS prices (
ticker VARCHAR,
day DATE,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume BIGINT,
PRIMARY KEY (ticker, day)
)
""",
"""
CREATE TABLE IF NOT EXISTS runs (
started_at TIMESTAMPTZ,
model VARCHAR,
input_tokens BIGINT,
output_tokens BIGINT,
hits BIGINT
)
""",
]
def connect(read_only=False):
con = duckdb.connect(DB, read_only=read_only)
con.execute("SET memory_limit='512MB'")
con.execute('SET threads=2')
if not read_only:
for statement in SCHEMA:
con.execute(statement)
return con(ticker, day) पर मौजूद primary key ही रिफ्रेश को बार-बार सुरक्षित रूप से चलाने योग्य बनाती है। INSERT OR REPLACE उस ticker और उस दिन के लिए पहले से मौजूद row को ओवरराइट कर देता है, इसलिए backfill को दो बार चलाने से टेबल में डेटा दोगुना नहीं होता। यदि key न हो, तो क्रैश के बाद दोबारा चलाने पर हर bar चुपचाप डुप्लीकेट हो जाएगा, और उसके बाद आप जो भी औसत (average) निकालेंगे वह गलत होगा, जबकि आपको सूचित करने के लिए कोई error message भी नहीं मिलेगा।
DuckDB किसी फाइल को लिखने के लिए एक समय में केवल एक ही process को अनुमति देता है। दूसरा writer तुरंत Could not set lock on file के साथ विफल हो जाता है, जिसके बाद उस PID का उल्लेख होता है जो फाइल को होल्ड किए हुए है; व्यवहार में यह वही इंटरैक्टिव duckdb शेल होता है जिसे आपने किसी दूसरे टर्मिनल में खुला छोड़ दिया है। Readers read_only=True पास करते हैं, इसीलिए connect इस फ्लैग का उपयोग करता है। यदि कई processes को वास्तव में एक ही समय पर लिखने की आवश्यकता है, तो यह किसी अन्य इंजन का काम है: SQLite का WAL मोड रीडर्स को काम करने देता है जबकि एक writer commit करता है, और एक busy timeout अन्य writers को विफल होने के बजाय प्रतीक्षा करने के लिए मजबूर करता है। सर्वर वर्कलोड के लिए DuckDB बनाम SQLite दोनों की तुलना करता है, और VPS पर प्रोडक्शन में SQLite चलाना उन सेटिंग्स को कवर करता है जो WAL को सही ढंग से काम करने में मदद करती हैं।
रिफ्रेश जॉब
# /opt/research/refresh.py
import sys
import pandas as pd
import yfinance as yf
from store import connect
TICKERS = ['AAPL', 'MSFT', 'KO', 'SAP', 'TSM']
FIRST_DAY = '2016-01-01'
COLS = ['ticker', 'day', 'open', 'high', 'low', 'close', 'volume']
def fetch(ticker, start):
df = yf.Ticker(ticker).history(start=start, auto_adjust=True)
if df.empty:
return None
df = df.reset_index()
stamps = pd.to_datetime(df['Date'])
if stamps.dt.tz is not None:
stamps = stamps.dt.tz_localize(None)
df['day'] = stamps.dt.date
df['ticker'] = ticker
df = df.rename(columns={'Open': 'open', 'High': 'high', 'Low': 'low',
'Close': 'close', 'Volume': 'volume'})
return df[COLS]
def main():
con = connect()
empty = 0
for ticker in TICKERS:
last = con.execute('SELECT max(day) FROM prices WHERE ticker = ?',
[ticker]).fetchone()[0]
rows_df = fetch(ticker, str(last) if last else FIRST_DAY)
if rows_df is None:
print(ticker + ': feed returned no rows', file=sys.stderr)
empty += 1
continue
con.register('rows_df', rows_df)
con.execute('INSERT OR REPLACE INTO prices '
'SELECT ticker, day, open, high, low, close, volume FROM rows_df')
print(ticker + ': ' + str(len(rows_df)) + ' rows')
con.close()
if empty == len(TICKERS):
print('every ticker returned nothing: the feed is broken', file=sys.stderr)
sys.exit(1)
main()इसे एक बार मैन्युअल रूप से चलाएं:
sudo -u research /opt/research/venv/bin/python /opt/research/refresh.pyपहली बार चलाने पर यह कई वर्षों का डेटा बैकफिल करता है और प्रति टिकर हजारों की संख्या में पंक्तियाँ प्रिंट करता है। एक मिनट बाद इसे दोबारा चलाएं, अब प्रत्येक लाइन में केवल 1 या 2 पंक्तियाँ दिखनी चाहिए, क्योंकि जॉब उस अंतिम दिन से शुरू होती है जो पहले से ही स्टोर है। वह दूसरा रन ही असली टेस्ट है: यदि संख्या अभी भी हजारों में है, तो max(day) कुछ भी रिटर्न नहीं कर रहा है और इंसर्ट हर रात आपके पूरे इतिहास को फिर से लिख रहा है।
df.empty चेक फाइल की सबसे महत्वपूर्ण लाइन है। Ticker.history() गलत या डीलिस्ट किए गए सिंबल के लिए एरर नहीं देता है। यह केवल एक चेतावनी प्रिंट करता है कि सिंबल संभवतः डीलिस्ट हो गया है और कोई प्राइस डेटा नहीं मिला (सटीक शब्दों का चयन लाइब्रेरी वर्जन के अनुसार बदलता रहता है) और एक खाली DataFrame रिटर्न करता है। बिना उस चेक के जॉब कुछ भी नहीं लिखती, 0 के साथ एग्जिट हो जाती है, और systemd एक सफल रन दिखाता है जबकि टेबल चुपचाप बढ़ना बंद कर देती है। आपको इसका पता हफ्तों बाद चलता है, जब स्क्रीन पर हर दिन वही पुरानी पंक्तियाँ दिखाई देती हैं।
टाइमस्टैम्प हैंडलिंग का भी एक कारण है। फीड द्वारा रिटर्न किया गया इंडेक्स एक्सचेंज का टाइमज़ोन ले सकता है, और ऑफसेट को हटाना UTC में बदलने के समान नहीं है। टोक्यो सत्र का मिडनाइट लोकल टाइम UTC में पिछले कैलेंडर दिन में बदल जाता है, इसलिए UTC कन्वर्जन चुपचाप हर जापानी बार को एक दिन पीछे खिसका देता है और प्राइमरी की को खराब कर देता है। tz_localize(None) एक्सचेंज के अपने सत्र की तारीख को बनाए रखता है, जिसका अर्थ डेली बार के संदर्भ में वही होता है।
बाजार बंद होने पर चलने वाला टाइमर
अमेरिकी बाजार न्यूयॉर्क समय के अनुसार 16:00 बजे बंद होते हैं और अंतिम प्रिंट्स को सेटल होने में कुछ मिनट लगते हैं, इसलिए यह जॉब 16:20 पर चलती है। इसे UTC में नहीं, बल्कि न्यूयॉर्क समय में लिखें। न्यूयॉर्क में सर्दियों में UTC माइनस 5 और गर्मियों में UTC माइनस 4 का समय होता है, इसलिए एक निश्चित UTC घंटे के रूप में लिखा गया टाइमर साल में दो बार एक घंटे का अंतर पैदा करता है और बाजार बंद होने से पहले ही चलने लगता है। systemd 252 और उसके बाद के संस्करण सीधे OnCalendar में टाइमज़ोन स्वीकार करते हैं, और Ubuntu 24.04 में 255 संस्करण आता है। अपना संस्करण systemctl --version से सुनिश्चित करें।
# /etc/systemd/system/research-refresh.service
[Unit]
Description=Refresh market data and run the daily screen
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=research
Group=research
WorkingDirectory=/opt/research
EnvironmentFile=/etc/research/env
ExecStart=/opt/research/venv/bin/python /opt/research/refresh.py
ExecStart=/opt/research/venv/bin/python /opt/research/screen.py# /etc/systemd/system/research-refresh.timer
[Unit]
Description=Run the refresh after the US market close
[Timer]
OnCalendar=Mon-Fri 16:20 America/New_York
Persistent=true
RandomizedDelaySec=180
[Install]
WantedBy=timers.targetType=oneshot एकमात्र सर्विस टाइप है जो एक से अधिक ExecStart स्वीकार करता है, और यह उन्हें क्रम में चलाता है, यदि कोई नॉन-जीरो (non-zero) एग्जिट कोड देता है तो यह रुक जाता है। आपको बिल्कुल यही व्यवहार चाहिए: एक विफल रिफ्रेश के बाद पुराने डेटा पर स्क्रीन अपडेट नहीं होनी चाहिए। Persistent=true उन VPS पर महत्वपूर्ण है जो कर्नल अपडेट के लिए रीबूट होते हैं। इसके बिना 16:15 पर रीबूट होने पर रन छूट जाता है; इसके साथ, मशीन वापस आते ही जॉब चल जाती है।
sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timerlist-timers में एक NEXT कॉलम दिखना चाहिए जिसमें अगले कार्यदिवस का रन समय हो, जिसे सर्वर के स्थानीय समय में परिवर्तित किया गया हो। खाली सूची का अर्थ है कि टाइमर इनेबल नहीं है, या यूनिट में [Install] सेक्शन गायब है, जिसके कारण enable के पास timers.target में लिंक करने के लिए कुछ नहीं था।
स्क्रीन: पहले SQL, अंत में मॉडल
SQL सटीक है और इसे चलाने में कोई अतिरिक्त लागत नहीं आती। मॉडल के साथ ऐसा नहीं है। इसलिए, क्वेरी डेटा के दायरे को सीमित करती है, और केवल परिणामी डेटा ही मॉडल को भेजा जाता है। इसे /opt/research/screen.sql के रूप में सेव करें।
WITH ma AS (
SELECT ticker, day, close,
avg(close) OVER w20 AS ma20,
avg(close) OVER w50 AS ma50,
row_number() OVER (PARTITION BY ticker ORDER BY day) AS n
FROM prices
WINDOW
w20 AS (PARTITION BY ticker ORDER BY day ROWS BETWEEN 19 PRECEDING AND CURRENT ROW),
w50 AS (PARTITION BY ticker ORDER BY day ROWS BETWEEN 49 PRECEDING AND CURRENT ROW)
)
SELECT ticker, day, close, round(ma20, 2) AS ma20, round(ma50, 2) AS ma50
FROM ma
WHERE n > 50 AND ma20 > ma50
ORDER BY day DESC, ticker
LIMIT 20;n > 50 फ़िल्टर केवल दिखावट के लिए नहीं है। ROWS BETWEEN 49 PRECEDING AND CURRENT ROW मौजूद सभी पंक्तियों का औसत निकालता है, इसलिए किसी टिकर की तीसरी पंक्ति तीन दिनों का औसत दिखाती है और उसे फिर भी ma50 ही कहा जाता है। यदि आप इसकी तुलना ma20 से करते हैं, तो आप हर टिकर के इतिहास की शुरुआत में एक ऐसा क्रॉसओवर बना बैठेंगे जो कभी हुआ ही नहीं था। पंक्ति संख्या (row number) पर फ़िल्टर लगाने से वे पंक्तियाँ हट जाती हैं जहाँ विंडो पूरी नहीं थी।
मॉडल क्या देखता है और क्या कभी नहीं देखता
# /opt/research/screen.py
from anthropic import Anthropic
from store import connect
SYSTEM = (
'You are a research assistant. Use only the rows in the message. '
'If a number is not in the rows, say that it is not available. '
'Do not give investment advice, price targets or buy and sell calls. '
'Write at most 200 words.'
)
con = connect()
sql = open('/opt/research/screen.sql').read()
rows = con.execute(sql).fetchall()
block = '\n'.join(' / '.join(str(v) for v in row) for row in rows)
client = Anthropic(max_retries=5) # reads ANTHROPIC_API_KEY from the environment
resp = client.messages.create(
model='claude-sonnet-5',
max_tokens=600,
system=SYSTEM,
messages=[{'role': 'user', 'content': 'Screen hits, ticker / day / close / ma20 / ma50:\n' + block}],
)
print(resp.content[0].text)
con.execute('INSERT INTO runs VALUES (now(), ?, ?, ?, ?)',
['claude-sonnet-5', resp.usage.input_tokens,
resp.usage.output_tokens, len(rows)])
con.close()सिस्टम प्रॉम्प्ट में शब्द सीमा बिल के महंगे हिस्से को सीमित करती है। केवल दी गई पंक्तियों का उपयोग करने के निर्देश को आपको विश्वास करने के बजाय सत्यापित करना होगा: ब्लॉक से एक कॉलम हटाएँ, इसे फिर से चलाएँ और आउटपुट पढ़ें। यदि उस कॉलम के लिए कोई आंकड़ा अभी भी दिखाई देता है, तो मॉडल ने रिक्त स्थान को भर दिया है, और आपका प्रॉम्प्ट पर्याप्त रूप से सटीक नहीं है। वह परीक्षण दो मिनट लेता है और यह पता लगाने का एकमात्र ईमानदार तरीका है।
मॉडल कभी भी API key नहीं देखता, database path कभी नहीं देखता, और कभी कोई query नहीं चलाता। यह पंक्तियाँ प्राप्त करता है और गद्य (prose) लौटाता है। वह सीमा ही आउटपुट को जाँचने योग्य बनाती है, क्योंकि नोट में प्रत्येक संख्या आपके द्वारा भेजे गए ब्लॉक में भी दिखाई देनी चाहिए, और आप उनकी पंक्ति-दर-पंक्ति तुलना कर सकते हैं। इस विषय पर प्रॉम्प्टिंग के बारे में अधिक जानकारी के लिए, वित्तीय विश्लेषण के लिए Claude का उपयोग इस बात पर विस्तार से चर्चा करता है कि मॉडल क्या पढ़ने में अच्छा है।
एक रन, एंड-टू-एंड
न्यूयॉर्क समय अनुसार 16:20 पर टाइमर सर्विस को शुरू करता है। refresh.py अंतिम संग्रहीत दिन से शुरू होने वाले प्रत्येक टिकर के लिए फीड से डेटा मांगता है, प्रत्येक के लिए एक या दो नई बार (bars) लिखता है, और प्रति टिकर एक लाइन प्रिंट करता है। screen.py उसी फाइल को खोलता है, मूविंग एवरेज क्वेरी चलाता है, और कुछ पंक्तियाँ (rows) वापस प्राप्त करता है। ये पंक्तियाँ कुछ सौ टोकन का एक टेक्स्ट ब्लॉक बन जाती हैं। एक API कॉल उन्हें एक संक्षिप्त नोट में बदल देती है, नोट जर्नल में चला जाता है, और runs में टोकन काउंट और हिट्स की संख्या के साथ एक पंक्ति दर्ज हो जाती है।
journalctl -u research-refresh.service -n 50 --no-pagerएक स्वस्थ लॉग में प्रति टिकर एक लाइन होती है, उसके बाद नोट, और फिर research-refresh.service: Deactivated successfully होता है। एक सप्ताह के बाद, डेटाबेस से अपनी लागत का विवरण निकालें:
SELECT count(*) AS runs,
sum(input_tokens) AS in_tokens,
sum(output_tokens) AS out_tokens
FROM runs;उन कुल योगों को उस प्रति मिलियन टोकन मूल्य से गुणा करें जो आपका मॉडल उस दिन दिखाता है जिस दिन आप उन्हें पढ़ते हैं। यह आपको किसी और के अनुमान के बजाय वास्तविक आंकड़ा देता है। प्रकाशित मूल्य बदलते रहते हैं। गणित नहीं बदलता।
बैकटेस्ट ओवरफिट क्यों होते हैं और इसे कैसे पहचानें
स्क्रीन को दो विंडो की लंबाई के फलन (function) के रूप में लिखें, पेयर्स के ग्रिड को स्वीप करें और उन्हें रिटर्न के आधार पर रैंक करें। सबसे अच्छा पेयर बेहतरीन दिखेगा। यही समस्या है, परिणाम नहीं। 200 पेयर्स का ग्रिड 200 प्रयोगों के बराबर है, और आपने सबसे भाग्यशाली को चुन लिया है।
आप इसे दस मिनट में होते हुए देख सकते हैं। स्टोर को तारीख के अनुसार दो हिस्सों में बाँटें। केवल पहले हिस्से पर ग्रिड को स्वीप करें और विजेता को नोट करें। दूसरे हिस्से पर उसी ग्रिड को स्वीप करें। यदि दोनों विजेता एक-दूसरे से बहुत अलग हैं, तो पैरामीटर्स केवल नॉइज़ (noise) को फिट कर रहे हैं, और जो पेयर केवल उसी आधे हिस्से पर जीतता है जिस पर आपने उसे ट्यून किया था, वह कल के बारे में कुछ नहीं बताता।
सर्वाइवरशिप (Survivorship) ओवरफिटिंग से भी बदतर है क्योंकि ट्यूनिंग इसे ठीक नहीं कर सकती। आपकी टिकर लिस्ट आज के इंडेक्स मेंबर्स की है, इसलिए इसमें केवल वही कंपनियाँ शामिल हैं जो बची हुई हैं। फीड से किसी ऐसे टिकर के बारे में पूछें जिसे 2019 में डीलिस्ट कर दिया गया था, तो यह एक खाली फ्रेम लौटाएगा, जिसका अर्थ है कि वह कंपनी कभी आपके स्टोर में नहीं आई और कभी आपके टेस्ट में नहीं आई। आपके द्वारा चलाए गए हर बैकटेस्ट ने पहले ही विफलताओं को बाहर कर दिया है।
रीस्टेटेड फंडामेंटल्स (Restated fundamentals) टाइमलाइन को तोड़ देते हैं। 2019 की तिमाही के लिए API आज जो रेवेन्यू फिगर देता है, वह हमेशा वही फिगर नहीं होता जो 2019 में पब्लिश हुआ था। आज के फंडामेंटल्स को 2019 की कीमतों के साथ मिलाने वाली स्क्रीन ऐसी जानकारी का उपयोग कर रही है जो उस समय मौजूद ही नहीं थी। कीमतें यहाँ आमतौर पर सुरक्षित होती हैं। फंडामेंटल्स आमतौर पर नहीं होते।
एडजस्टेड कीमतें आपके नीचे बदलती रहती हैं। auto_adjust=True के साथ क्लोजिंग प्राइसेस को डिविडेंड और स्प्लिट के लिए पीछे की ओर एडजस्ट किया जाता है, इसलिए अगले महीने चलाई गई वही क्वेरी थोड़ा अलग इतिहास लौटाती है। जिन पंक्तियों (rows) का आपने वास्तव में उपयोग किया है, उन्हें स्टोर करना ही परिणाम को पुनरुत्पादनीय (reproducible) बनाता है, और यही एक और कारण है कि लोकल स्टोर मौजूद है।
बैकटेस्ट कमीशन और स्लिपेज को भी अनदेखा करता है, और यह मान लेता है कि आपका ऑर्डर कीमत को प्रभावित नहीं करता है। ये निष्पादन (execution) का हिस्सा हैं, जो यहाँ दायरे से बाहर हैं और VPS पर ट्रेडिंग बॉट्स चलाना में कवर किए गए हैं।
विफलता के प्रकार और वे संदेश जो आपको दिखाई देंगे
error: externally-managed-environment जब pip चलता है। आप virtual environment के बाहर हैं। /opt/research/venv/bin/pip को उसके पूर्ण path के साथ कॉल करें।
Could not set lock on file, जिसके बाद एक PID है। कोई अन्य process DuckDB फ़ाइल को लिखने के लिए open रखे हुए है, आमतौर पर यह कोई interactive shell होता है जिसे आप बंद करना भूल गए हैं। इसे बंद करें, या read_only=True के साथ दूसरा connection खोलें।
Main process exited, code=killed, status=9/KILL जो systemctl status में है। kernel ने memory की कमी के कारण job को समाप्त कर दिया है। journalctl -k | grep -i oom के साथ इसकी पुष्टि करें, फिर store.py में memory_limit को कम करें।
एक सफल रन जो कुछ भी नहीं लिखता है। systemctl status, active (exited) को पढ़ता है और table का आकार नहीं बढ़ा है। feed ने खाली frames वापस भेजे हैं। यह विफलता सबसे लंबे समय तक छिपी रहती है, इसलिए जब हर ticker खाली वापस आए तो job को non-zero exit code के साथ समाप्त करें।
timer का market holiday पर चलना। systemd को exchange calendar की जानकारी नहीं होती है, इसलिए Mon-Fri में छुट्टियां शामिल होती हैं। रन होता है, feed में कुछ भी नया नहीं होता है, और job को इसे त्रुटि के बजाय सामान्य स्थिति मानना चाहिए।
API से 429। आप rate limit से अधिक का उपयोग कर रहे हैं। Anthropic SDK अपने आप backoff के साथ पुनः प्रयास करता है, और Anthropic(max_retries=5) प्रयास की संख्या को बढ़ाता है। यदि यह अभी भी हर दिन विफल रहता है, तो job एक ही बार में बहुत अधिक डेटा मांग रही है।
यह क्या नहीं है
यह एक रिसर्च असिस्टेंट है। किसी फाइलिंग का सारांश तैयार करने वाला मॉडल उस फाइलिंग का एक विश्लेषण प्रस्तुत करता है, और यह टेक्स्ट में छपे किसी नंबर के बारे में पूरी तरह गलत हो सकता है। इसीलिए नोट में दी गई हर संख्या को आपके द्वारा भेजे गए डेटा के साथ मिलाना अनिवार्य है। इस आउटपुट को केवल उन चीजों की सूची मानें जिन्हें आपको स्वयं पढ़ना चाहिए। यहाँ दी गई कोई भी जानकारी वित्तीय सलाह नहीं है, और न ही यह कोई संकेत (signal) है।
Backtests विचारों को खारिज करने के लिए उपयोगी होते हैं, लेकिन उन्हें प्रमाणित करने में कमजोर होते हैं। जो रणनीति आपके अपने डेटा पर विफल हो जाती है, वह वास्तव में मृत है। जो रणनीति पास हो जाती है, उसने केवल आपके डेटा का परीक्षण पार किया है, जो कि रात के 1 बजे महसूस होने वाले दावे से कहीं छोटा दावा है।
Execution को जानबूझकर दायरे से बाहर रखा गया है। Orders और broker credentials का जोखिम प्रोफाइल read-only रिसर्च बॉक्स से अलग होता है, और उन्हें मिलाने का मतलब है trading keys को उसी मशीन पर रखना जहाँ LLM prompt चल रहा है। यदि आप यह देखना चाहते हैं कि यह पैटर्न आपके अपने सर्वर पर चलने वाली अन्य उपयोगी चीजों के साथ कहाँ स्थित है, तो the self-hosted AI agents worth running एक विस्तृत टूर है।
FAQ
क्या मुझे पेड मार्केट डेटा फीड की आवश्यकता है?
प्रोटोटाइप के लिए नहीं। सीखने के उद्देश्य से एक मुफ्त अनौपचारिक फीड सिस्टम की संरचना समझने के लिए पर्याप्त है। यह कभी भी बंद हो सकती है, क्योंकि यह ऐसी वेबसाइट पर निर्भर है जो आपको कोई सेवा देने के लिए बाध्य नहीं है। यह आमतौर पर exception के बजाय खाली frames के रूप में विफल होती है, इसलिए आपके job को row counts की जांच करनी चाहिए। जब डेटा का उपयोग निर्णय लेने के लिए किया जाने लगे, तो एक documented API और support address वाली पेड फीड पर स्विच करें। स्टोर के कारण यह बदलाव सस्ता हो जाता है: केवल fetch function बदलता है, जबकि schedule, schema और screen वैसे ही रहते हैं।
क्या मुझे कीमतें SQLite में रखनी चाहिए या DuckDB में?
DuckDB columnar है और इसे कई rows को स्कैन करके aggregate निकालने के लिए बनाया गया है, जो कि दस वर्षों के bars का moving average निकालने जैसा ही है। SQLite row-oriented है और एक साथ कई processes से होने वाले छोटे reads और writes के लिए बेहतर है। एक scheduled job के लिए जो कुछ सौ rows append करता है और फिर लाखों को स्कैन करता है, DuckDB बेहतर विकल्प है। यदि कई processes को एक साथ लिखना हो, तो WAL mode में SQLite readers को काम करने देता है जबकि एक writer commit करता है, और busy timeout अन्य writers को विफल होने के बजाय प्रतीक्षा करने के लिए मजबूर करता है।
LLM calls का मासिक खर्च कितना होता है?
हर response से usage.input_tokens और usage.output_tokens को एक table में log करें, फिर अपने साप्ताहिक totals को उस प्रति मिलियन टोकन कीमत से गुणा करें जो आपके model की वेबसाइट पर उस दिन दी गई है। यही एकमात्र आंकड़ा है जो अगली तिमाही तक सही रहेगा। एक short screen पर दैनिक run में बहुत कम calls होती हैं, और note की लंबाई वह हिस्सा है जिसे आप नियंत्रित कर सकते हैं: summary को 200 शब्दों तक सीमित रखने से कम rows भेजने की तुलना में अधिक बचत होती है, क्योंकि हर Claude model पर output tokens की कीमत input tokens से अधिक होती है।
मेरा job सफल क्यों रहा लेकिन कोई नई row क्यों नहीं लिखी गई?
इसके दो सामान्य कारण हैं। मार्केट बंद था, क्योंकि systemd Mon-Fri schedule में exchange की छुट्टियां शामिल होती हैं। या फिर फीड ने हर ticker के लिए एक खाली frame लौटाया, जिसे client libraries अक्सर exception के बजाय केवल एक printed warning के रूप में दिखाती हैं, इसलिए process 0 के साथ exit होती है और systemd इसे सफल (green) दिखाता है। SELECT max(day) FROM prices की तुलना पिछले वास्तविक ट्रेडिंग दिन से करके इनमें अंतर पहचानें, और जब हर ticker खाली आए तो job को non-zero exit code के साथ बंद करें।
क्या एजेंट यह तय कर सकता है कि क्या खरीदना है?
नहीं, और इसे ऐसा करने के लिए बनाना ही वह बिंदु है जहाँ ये प्रोजेक्ट्स गलत दिशा में चले जाते हैं। model के पास मार्केट का एक्सेस नहीं है, उसे आपकी position या tax की स्थिति का पता नहीं है, और उसके पास अपने आंकड़ों को किसी भी चीज़ से सत्यापित करने का कोई तरीका नहीं है। वह बड़ी मात्रा में टेक्स्ट पढ़ने और यह बताने में अच्छा है कि आज किन कुछ items पर आपको ध्यान देना चाहिए। उसके द्वारा लिखा गया कुछ भी वित्तीय सलाह नहीं है, और निर्णय, तथा उसकी जिम्मेदारी, आपके पास ही रहती है।