SSD Nodes Learn Hosting plans →
নির্দেশিকা Matt Connorদ্বারা Matt Connor

VPS-এ নিজের স্টক রিসার্চ এজেন্ট তৈরির সম্পূর্ণ গাইড

Python, DuckDB এবং systemd ব্যবহার করে কীভাবে নিজস্ব স্টক রিসার্চ এজেন্ট তৈরি করবেন তা শিখুন। মার্কেট ক্লোজিং ডেটা প্রসেস করতে Claude API এবং অটোমেশনের সঠিক নিয়ম জানুন।

সেলফ-হোস্টেড স্টক রিসার্চ এজেন্ট কী

একটি সেলফ-হোস্টেড স্টক রিসার্চ এজেন্ট হলো আপনার নিজস্ব সার্ভারে চলা একটি ছোট প্রোগ্রাম, যা নির্দিষ্ট সময় পরপর মার্কেট ডেটা সংগ্রহ করে, তা একটি লোকাল ডেটাবেসে জমা রাখে, ডেটা ফিল্টার করে এবং কী পরিবর্তন হলো তা লিখে দেওয়ার জন্য একটি লার্জ ল্যাঙ্গুয়েজ মডেল (LLM)-কে অনুরোধ করে। এটি ডেটা পড়ে এবং ফিল্টার করে। এটি ট্রেড করে না এবং এই গাইডের কোনো কিছুই আর্থিক পরামর্শ নয়।

দুজন ব্যক্তি শূন্য থেকে এটি তৈরি করলে ভিন্ন ভিন্ন library বেছে নিতে পারেন, তবু শেষ পর্যন্ত একই চারটি অংশ থাকবে: একটি feed, যা price ও fundamental data সরবরাহ করে; একটি local store, যেখানে আপনি কখনও fetch করা প্রতিটি row সংরক্ষিত থাকে; একটি job, যা নির্দিষ্ট সময় পরপর store refresh করে; এবং একটি LLM layer, যা অবশিষ্ট row-গুলোকে বাক্যে রূপ দেয়। এই guide Python, DuckDB, একটি systemd timer এবং Claude API (application programming interface) ব্যবহার করে সেই কাঠামো তৈরি করে। এটি একটি নির্দিষ্ট loop-সহ সীমিত পরিসরের agent। তাই আগে যদি সাধারণ সংস্করণটি বুঝতে চান, AI agent শেখার ধাপে ধাপে পথ কোনো domain problem-এ প্রয়োগ করার আগে loop, tool এবং memory ক্রমানুসারে ব্যাখ্যা করে। Execution একটি পৃথক job, যার failure mode-ও পৃথক। তাই এটি trading bot-এর জন্য প্রস্তুত VPS-এ চালানো উচিত।

চারটি অংশ এবং প্রতিটি অংশের কাজ

The feed হলো একমাত্র অংশ যা বাইরের জগতের সাথে যোগাযোগ করে। এটি জানে কীভাবে একটি ticker এবং তারিখের পরিসীমা (date range) চাইতে হয় এবং কীভাবে সারিগুলো (rows) ফেরত দিতে হয়। আপনার ডেটাবেসের পরবর্তী সব অংশ feed-এর পরিবর্তে সরাসরি ডেটাবেস থেকে তথ্য পড়ে, তাই feed বন্ধ হয়ে গেলেও আপনার পুরো সিস্টেম ভেঙে পড়ে না, বরং কেবল একদিনের নতুন ডেটা মিস হয়।

The store হলো পুরো প্রক্রিয়ার মূল লক্ষ্য। প্রতিদিনের ক্লোজিং ডেটা যা আপনি রেকর্ড করেননি, তা সাধারণত পরে আবার সংগ্রহ করা যায়। কিন্তু intraday quote, সংশোধনের আগের কোনো অনুমান (estimate), অথবা পুনরায় হিসাব করার আগের কোনো মৌলিক পরিসংখ্যান (fundamentals figure) পরে আর পাওয়া যায় না। ডেটা যেদিন যা বলেছিল, তার একটি রেকর্ড ধরে রাখার উপায় হলো এই store।

The scheduler নির্ধারণ করে কখন refresh প্রক্রিয়াটি ঘটবে। সার্ভারে এটি একটি systemd timer হিসেবে কাজ করে, আর এই কারণেই কোডের চেয়ে VPS এখানে বেশি গুরুত্বপূর্ণ।

The LLM layer আপনার SQL থেকে তৈরি হওয়া একটি ছোট টেক্সট ব্লক পড়ে এবং তার একটি সারাংশ লেখে। এটি কখনোই সরাসরি ডেটাবেসের সাথে সংযুক্ত হয় না এবং কখনোই কুয়েরি (query) তৈরি করে না। যদি মডেলটি নিজেই SQL লেখে, তবে একটি ভুল টোকেন একটি সাবলীল বাক্যের ভেতরে ভুল সংখ্যা তৈরি করতে পারে, যা যাচাই করার কোনো উপায় থাকে না। কিন্তু যদি SQL সংখ্যাগুলো তৈরি করে, তবে মডেলটি কেবল বর্ণনায় ভুল করতে পারে, আর আপনি সেই বর্ণনাকে আপনার পাঠানো সারিগুলোর সাথে মিলিয়ে যাচাই করে নিতে পারেন।

ল্যাপটপের পরিবর্তে কেন VPS-এ চালাবেন

এর কারণ হলো শিডিউলার। নিউ ইয়র্ক সময় 16:00-তে মার্কিন বাজার বন্ধ হওয়ার সময় বার্লিনে সময় হয় 22:00 এবং জাকার্তাতে পরের দিন ভোর 04:00। এই উভয় সময়েই একটি ল্যাপটপ সাধারণত স্লিপ মোডে থাকে। একটি রান মিস করলে তা কেবল দেরিতে নোটিফিকেশন পাওয়ার চেয়েও বেশি ব্যয়বহুল: দৈনিক বারগুলো সাধারণত পরে পুনরায় সংগ্রহ করা যায়, কিন্তু যা একবার সংশোধিত হয়ে যায় তা আর ফিরে পাওয়া সম্ভব নয়, তাই আপনার রেকর্ডে সেই শূন্যস্থান স্থায়ী হয়ে যায়। একই যুক্তি যেকোনো এজেন্টের ক্ষেত্রে প্রযোজ্য যার শিডিউল এবং সংরক্ষিত স্টেটকে রিবুট-এর পরেও টিকে থাকতে হয়, আর এই বিষয়টিই আপনার নিজস্ব VPS-এ KiroCrew-কে সবসময় সচল এজেন্ট হিসেবে চালানো-এর মূল ভিত্তি।

দ্বিতীয় কারণটি ছোট হলেও বাস্তব। সার্ভারে একটি API key থাকে, একটি ফাইলে, যার মালিক এমন একজন সিস্টেম ইউজার যার কোনো লগইন শেল নেই এবং যা কেবল একটি কাজের জন্যই ব্যবহৃত হয়। আপনি যে ল্যাপটপ দিয়ে ওয়েব ব্রাউজ করেন, তাতে এই পরিবেশ তৈরি করা অনেক বেশি কঠিন। কোড এবং মডেলের ইনপুট থেকে key-টিকে দূরে রাখুন, যা AI এজেন্টের থেকে API key দূরে রাখা-এর মূল বিষয়।

ডিস্ক, RAM এবং token-এর হিসাব

ডিস্কের হিসাব সহজ। প্রতিদিনের একটি bar হলো প্রতিটি ticker ও trading day-এর জন্য একটি row, আর US trading year-এ প্রায় 252টি trading day থাকে।

চার্টRows in the prices table by watchlist size, at 252 trading days a year
এই চার্টের পেছনের ডেটা
[
  {
    "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"
  }
]

এক বছরে বিশটি ticker-এ 5,040টি row হবে। দশ বছর পরে 500 tickers line-এ 1,260,000টি row হবে। প্রতিটি row-তে একটি date এবং কয়েকটি double থাকে। DuckDB column compress করে সংরক্ষণ করে। তাই আকার gigabyte নয়, কয়েক দশ megabyte হবে। এই হিসাব, আমার হিসাবও, যাচাই না করে বিশ্বাস করবেন না। প্রথম backfill-এর পরে du -h /opt/research/data/market.duckdb চালিয়ে নিজের প্রকৃত আকার ব্যবহার করুন।

ছোট VPS-এ RAM দ্রুত সীমাবদ্ধতা হয়ে দাঁড়ায়। DuckDB ডিফল্টভাবে একটি query-এর জন্য মেশিনের memory এবং সব core-এর বড় অংশ ব্যবহার করে। Analytics server-এ এটি উপযুক্ত, কিন্তু 2 GB-এর এমন একটি box-এ নয় যেখানে অন্য service-ও চলছে। এরপর পুরো prices table-এ একটি aggregation চালালে kernel process-টি বন্ধ করে দেয়। systemd Main process exited, code=killed, status=9/KILL দেখায়, আর journalctl -k-এ out of memory kill দেখা যায়। memory_limit এবং threads স্পষ্টভাবে সেট করলে query বন্ধ না হয়ে ধীর হবে।

Token-এর পরিমাণ অনুমান নয়, মেপে নির্ধারণ করুন। প্রতিটি Messages API response-এ usage object থাকে, যার মধ্যে input_tokens এবং output_tokens থাকে। প্রতিটি call-এর সময় এই দুটিই একটি table-এ লিখুন। এক সপ্তাহ পরে আপনার প্রকৃত volume জানা যাবে। এরপর আপনি যে দিনে মূল্য দেখছেন, সেদিন model-এ তালিকাভুক্ত মূল্যের সঙ্গে সেই volume গুণ করুন। পরিকল্পনার জন্য দুটি বিষয় যথেষ্ট স্থিতিশীল। প্রতিটি Claude model-এ output token-এর মূল্য input token-এর চেয়ে বেশি। তাই note 200 word-এ সীমিত করলে পাঠানো data কমানোর চেয়ে bill বেশি কমে। আর দিনে একবার চলা job-এ prompt caching কাজে আসে না। Cache ডিফল্টভাবে 5 মিনিট থাকে। Anthropic-এর দেওয়া সর্বোচ্চ lifetime হলো 1 hour। এতে cache লিখতে base input price-এর 2x খরচ হয়, যেখানে 5 minute cache-এর জন্য খরচ 1.25x। যেভাবেই হোক, পরের দিনের run-এর সময় cached block-এর মেয়াদ শেষ হয়ে যায় এবং আবার পুরো input price দিতে হয়। একই বড় text block নিয়ে একটি run-এ অনেক call হলে caching সাশ্রয় করে।

উপাদানগুলো ইনস্টল করা

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 এড়িয়ে সরাসরি system Python-এ pip install চালান, তবে Ubuntu 24.04 আপনাকে error: externally-managed-environment দিয়ে আটকে দেবে। কারণ /usr/lib/python3 ডিরেক্টরিটি ডিস্ট্রিবিউশনের মালিকানাধীন এবং সেখানে pip-কে কোনো কিছু লিখতে দেওয়া হয় না। venv ব্যবহার করা কেবল সৌজন্য নয়, এটিই একমাত্র ডিরেক্টরি যেখানে pip-এর কাজ করার অনুমতি রয়েছে।

API key এমন একটি ফাইলে রাখুন যা কেবল সার্ভিস ইউজার পড়তে পারে এবং অন্য কেউ পারে না:

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 থাকার কারণেই refresh বারবার নিরাপদে চালানো যায়। INSERT OR REPLACE একই ticker ও দিনের জন্য আগে থেকে থাকা row overwrite করে। তাই backfill দুবার চালালেও table-এ duplicate row তৈরি হয় না। এই key না থাকলে crash-এর পরে আবার চালালে প্রতিটি bar নীরবে duplicate হয়। এরপর হিসাব করা প্রতিটি average ভুল হবে, অথচ তা জানানোর মতো কোনো error message থাকবে না।

DuckDB-তে লেখার জন্য file একসঙ্গে কেবল একটি process open রাখতে পারে। দ্বিতীয় writer সঙ্গে সঙ্গে Could not set lock on file-সহ ব্যর্থ হয়। এর পরে সেই file ধরে রাখা PID দেখায়। বাস্তবে সেটি সাধারণত অন্য terminal-এ খোলা রেখে দেওয়া interactive duckdb shell। Read-only connection ব্যবহার করলেও এই সীমাবদ্ধতা এড়ানো যায় না। DuckDB-এর concurrency documentation-এ দুটি mode বর্ণনা করা হয়েছে: একটি process read ও write করবে, অথবা একাধিক process শুধু read করবে। এই দুই mode একসঙ্গে ব্যবহার করা যায় না। connect-এ read_only flag দেওয়া যায়, যাতে একাধিক reader একই file share করতে পারে। তবে এটি কেবল তখনই কাজ করে, যখন কোনো writer file-টি open করে রাখে না। একই সময়ে একাধিক process-এর সত্যিই write করার প্রয়োজন হলে অন্য engine ব্যবহার করতে হবে। SQLite-এর WAL mode-এ একজন writer commit করার সময় reader-রা কাজ করতে পারে। busy timeout সেট করলে অন্য writer-রা ব্যর্থ না হয়ে অপেক্ষা করে। server workload-এর জন্য DuckDB বনাম SQLite-এ দুটি engine-এর তুলনা করা হয়েছে। VPS-এ production-এ SQLite চালানো-এ WAL সঠিকভাবে কাজ করার জন্য প্রয়োজনীয় settings ব্যাখ্যা করা হয়েছে।

রিফ্রেশ জব

# /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 ফেরত দেয়। এই চেকটি না থাকলে জবটি কোনো ডেটা না লিখেই সফলভাবে (exit 0) শেষ হবে, এবং systemd দেখাবে যে জবটি ঠিকঠাক চলছে, অথচ টেবিলের ডেটা আর বাড়ছে না। আপনি কয়েক সপ্তাহ পর বুঝতে পারবেন যে আপনার স্ক্রিনে প্রতিদিন একই ডেটা দেখাচ্ছে।

টাইমস্ট্যাম্প হ্যান্ডলিংয়ের পেছনেও একটি কারণ আছে। ফিড থেকে যে ইনডেক্স আসে তাতে এক্সচেঞ্জের টাইমজোন থাকতে পারে, আর অফসেট বাদ দেওয়া মানেই UTC-তে রূপান্তর করা নয়। টোকিও সেশনের মধ্যরাতকে UTC-তে রূপান্তর করলে তা আগের ক্যালেন্ডার দিনে চলে যায়। ফলে UTC রূপান্তর জাপানিজ বারগুলোকে নীরবে একদিন পিছিয়ে দেয় এবং প্রাইমারি কি (primary key) নষ্ট করে ফেলে। tz_localize(None) এক্সচেঞ্জের নিজস্ব সেশন ডেট বজায় রাখে, যা একটি ডেইলি বারের জন্য সঠিক মান।

বাজার বন্ধ হওয়ার সময় timer

US market নিউ ইয়র্ক সময় 16:00-এ বন্ধ হয়। শেষ লেনদেনের তথ্য স্থির হতে আরও কয়েক মিনিট লাগে। তাই job-টি 16:20-এ চালান। সময়টি UTC-তে নয়, নিউ ইয়র্ক সময়ে লিখুন। শীতকালে নিউ ইয়র্ক UTC-এর চেয়ে 5 ঘণ্টা পিছিয়ে থাকে এবং গ্রীষ্মকালে 4 ঘণ্টা পিছিয়ে থাকে। তাই নির্দিষ্ট UTC hour দিয়ে লেখা timer বছরে 2 বার 1 ঘণ্টা সরে যায় এবং market close-এর আগেই চালু হয়। systemd 235 এবং পরবর্তী সংস্করণে OnCalendar-এর মধ্যে সরাসরি timezone দেওয়া যায়। 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.target

Type=oneshot একমাত্র service type, যা একাধিক ExecStart গ্রহণ করে। এটি সেগুলো ক্রমানুসারে চালায় এবং কোনোটি non zero exit করলে থেমে যায়। এটিই আপনার প্রয়োজনীয় আচরণ। ব্যর্থ refresh-এর পরে পুরোনো data দেখানো চলবে না। Kernel update-এর জন্য reboot করা VPS-এ Persistent=true গুরুত্বপূর্ণ। এটি ছাড়া 16:15-এ reboot হলে run-টি সম্পূর্ণ হারিয়ে যাবে। এটি থাকলে machine ফিরে আসার সঙ্গে সঙ্গে job-টি চালু হবে। কোনো agent-কে চালু অবস্থায় থাকতে হলে, fire করে exit করার পরিবর্তে বিপরীত unit structure প্রয়োজন। সেখানে Restart= rules-সহ একটি long lived process থাকে এবং কোনো timer থাকে না। systemd দিয়ে VPS-এ headless dsh চালানো বিষয়টি এই পদ্ধতি দেখায়।

sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timer

list-timers-এ NEXT column-এ পরবর্তী weekday run দেখানোর কথা, যা server-এর নিজস্ব local time-এ রূপান্তরিত হবে। তালিকা খালি থাকলে timer enabled নয়, অথবা unit-এ [Install] section নেই। ফলে enable-এর কাছে timers.target-এর সঙ্গে link করার মতো কিছু ছিল না।

স্ক্রিন: প্রথমে SQL, সবশেষে মডেল

SQL সুনির্দিষ্ট এবং এটি চালাতে কোনো খরচ হয় না। মডেলের ক্ষেত্রে বিষয়টি এমন নয়। তাই কুয়েরি (query) ডেটার পরিধি কমিয়ে আনে এবং শুধুমাত্র টিকে থাকা ডেটাগুলোই মডেলে পাঠানো হয়। এটি /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-এর তুলনা করলে আপনি প্রতিটি টিকারের ইতিহাসের শুরুতে এমন একটি ক্রসওভার তৈরি করবেন যা বাস্তবে ঘটেনি। সারির সংখ্যার ওপর ভিত্তি করে ফিল্টার করলে সেই সারিগুলো বাদ পড়ে যায় যেখানে উইন্ডোটি পূর্ণ ছিল না।

মডেল কী দেখে এবং কী কখনো দেখে না

# /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,
    thinking={'type': 'disabled'},
    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()

এই মডেলে thinking লাইনটি গুরুত্বপূর্ণ। Claude Sonnet 5 ডিফল্টভাবে চিন্তা করে; এর thinking token-গুলো output হিসেবে বিল করা হয় এবং max_tokens-এর সীমার মধ্যে গণনা করা হয়। এগুলো text-এর আগে একটি block হিসেবে ফিরে আসে। ওই লাইনটি না থাকলে resp.content[0] thinking block হতে পারে এবং .text lookup ব্যর্থ হয়। Claude Sonnet 5 এখন একটি legacy model। এর উত্তরসূরি claude-sonnet-5-5, যা disabled প্রত্যাখ্যান করে। সেখানে ব্যবহার করার setting হলো thinking={'type': 'between_tools'}; এটি উত্তর দেওয়ার আগে thinking বন্ধ করে।

System prompt-এর word limit বিলের ব্যয়বহুল অংশের সীমা নির্ধারণ করে। শুধু দেওয়া row ব্যবহার করার নির্দেশনাটিই যাচাই করতে হবে, অন্ধভাবে বিশ্বাস করা যাবে না। Block থেকে একটি column মুছে আবার চালান এবং output পড়ুন। ওই column-এর কোনো figure যদি তবুও দেখা যায়, তাহলে মডেলটি অনুপস্থিত তথ্য পূরণ করেছে এবং আপনার prompt যথেষ্ট কঠোর নয়। এই পরীক্ষা করতে দুই মিনিট লাগে। এটি যাচাই করার একমাত্র নির্ভরযোগ্য উপায়।

মডেলটি কখনো API key দেখে না, database path দেখে না এবং কোনো query চালায় না। এটি row গ্রহণ করে এবং prose ফেরত দেয়। এই সীমারেখাই output যাচাইযোগ্য করে, কারণ note-এর প্রতিটি number আপনার পাঠানো block-এও থাকা উচিত এবং আপনি line by line তুলনা করতে পারেন। Web থেকে সংগৃহীত page-ও input-এ যুক্ত করলে এই সীমারেখার ধরন বদলে যায়। কারণ headline বা forum post আপনার জন্য তথ্যের বদলে মডেলের উদ্দেশে নির্দেশনা বহন করতে পারে। একটি agent-কে SearXNG search backend দেওয়া এই ঝুঁকির ক্ষেত্রটি তৈরি করে। Prompting নিয়ে আরও জানতে finance analysis-এর জন্য Claude ব্যবহার করা মডেল কোন ধরনের লেখা পড়তে ভালো তা আরও বিশদে ব্যাখ্যা করে।

একবার চালানো, শুরু থেকে শেষ পর্যন্ত

নিউ ইয়র্ক সময় 16:20-এ টাইমার সার্ভিসটি শুরু করে। refresh.py প্রতিটি টিকারের জন্য শেষ সংরক্ষিত দিন থেকে ফিড জিজ্ঞাসা করে, প্রতিটির জন্য এক বা দুটি নতুন বার (bar) লেখে এবং প্রতিটি টিকারের জন্য একটি করে লাইন প্রিন্ট করে। screen.py একই ফাইল খোলে, মুভিং এভারেজ কুয়েরি চালায় এবং কিছু সারি ফেরত পায়। এই সারিগুলো কয়েকশ টোকেনের একটি টেক্সট ব্লকে পরিণত হয়। একটি API কল সেগুলোকে একটি সংক্ষিপ্ত নোটে রূপান্তর করে, নোটটি জার্নালে যায় এবং টোকেন কাউন্ট ও হিটের সংখ্যাসহ একটি সারি runs-এ জমা হয়।

journalctl -u research-refresh.service -n 50 --no-pager

একটি সঠিক লগে প্রতিটি টিকারের জন্য একটি লাইন, তারপর নোট এবং শেষে research-refresh.service: Deactivated successfully থাকে। যদি আপনি সার্ভারে একটি Claude Code সেশন থেকে সেই লগটি পড়েন এবং একই সময়ে দ্বিতীয় একটি সেশন স্ক্রিন কুয়েরিতে কাজ করে, তবে দুটি সেশন একে অপরের কাছে টেক্সট আদান-প্রদান করতে পারে, আপনাকে টার্মিনালের মধ্যে লাইন কপি করতে হবে না। এক সপ্তাহ পর, ডাটাবেস থেকে আপনার নিজের খরচ বের করে পড়ুন:

SELECT count(*) AS runs,
       sum(input_tokens)  AS in_tokens,
       sum(output_tokens) AS out_tokens
FROM runs;

এই মোট সংখ্যাগুলোকে প্রতি মিলিয়ন টোকেনের দাম দিয়ে গুণ করুন, যা আপনার মডেল যেদিন আপনি পড়ছেন সেদিন তালিকাভুক্ত করেছে। এটি আপনাকে কারো অনুমানের পরিবর্তে প্রকৃত হিসাব দেবে। প্রকাশিত দাম পরিবর্তিত হয়। কিন্তু গাণিতিক হিসাব একই থাকে।

ব্যাকটেস্ট কেন overfit হয় এবং এটি কীভাবে ঘটতে দেখবেন

দুটি window length-এর ফাংশন হিসেবে screen-টি পুনর্লিখুন, pair-গুলোর একটি grid পরীক্ষা করুন এবং return অনুযায়ী সেগুলো rank করুন। সেরা pair-টি চমৎকার দেখাবে। সমস্যাটি ফলাফল নয়, এই প্রক্রিয়াটিই সমস্যা। 200টি pair-এর grid মানে 200টি experiment, এবং আপনি সবচেয়ে ভাগ্যবানটিই রেখে দিয়েছেন।

দশ মিনিটে এটি ঘটতে দেখতে পারেন। তারিখ অনুযায়ী store-টিকে অর্ধেক ভাগ করুন। শুধু প্রথম অর্ধেকে grid পরীক্ষা করে winner-টির নাম লিখে রাখুন। দ্বিতীয় অর্ধেকে একই grid পরীক্ষা করুন। দুই winner-এর মধ্যে বড় পার্থক্য থাকলে parameters noise-এর সঙ্গে fit করছে। আর আপনি যে অর্ধেকের জন্য tuning করেছেন, শুধু সেই অর্ধেকে জয়ী কোনো pair আগামীকাল সম্পর্কে কিছুই বলে না।

Survivorship overfitting-এর চেয়েও খারাপ, কারণ tuning এটি ঠিক করতে পারে না। আপনার ticker list আজকের index member-গুলো নিয়ে তৈরি, তাই এতে শুধু টিকে থাকা কোম্পানিগুলো আছে। 2019 সালে delist হওয়া কোনো ticker-এর জন্য feed-কে জিজ্ঞেস করলে এটি একটি empty frame ফেরত দেয়। এর অর্থ, ওই কোম্পানি কখনো আপনার store-এ ঢোকে না এবং আপনার test-এও ঢোকে না। আপনি যে প্রতিটি backtest চালান, তাতে ব্যর্থ কোম্পানিগুলো আগেই বাদ পড়ে গেছে।

Restated fundamentals timeline ভেঙে দেয়। API আজ 2019 সালের কোনো quarter-এর জন্য যে revenue figure ফেরত দেয়, সেটি 2019 সালে প্রকাশিত figure-এর সঙ্গে সবসময় এক নয়। 2019 সালের prices-এর সঙ্গে আজকের fundamentals মিশিয়ে কোনো screen চালালে এমন information ব্যবহার করা হয়, যা তখন বিদ্যমান ছিল না। Prices সাধারণত এখানে নিরাপদ। Fundamentals সাধারণত নিরাপদ নয়।

Adjusted prices আপনার নিচের মাটি সরিয়ে দেয়। auto_adjust=True-এর ক্ষেত্রে dividends এবং splits-এর জন্য closes-গুলোকে অতীতের দিকে adjust করা হয়। তাই একই query পরের মাসে চালালে সামান্য ভিন্ন history ফেরত আসে। আপনি বাস্তবে যে rows ব্যবহার করেছেন সেগুলো store করলেই result reproducible হয়। local store থাকার এটিও একটি কারণ।

Backtest commissions এবং slippage-ও উপেক্ষা করে। এটি ধরে নেয় যে আপনার order price-কে প্রভাবিত করে না। এগুলো execution-এর অংশ, যা এখানে scope-এর বাইরে এবং VPS-এ trading bot চালানো-এ আলোচনা করা হয়েছে।

ব্যর্থতার ধরন এবং যে বার্তাগুলো দেখবেন

error: externally-managed-environment যখন pip চলে। আপনি virtual environment-এর বাইরে আছেন। /opt/research/venv/bin/pip-কে পূর্ণ path দিয়ে চালান।

Could not set lock on file, এর পরে একটি PID আছে। অন্য একটি process DuckDB file-টি লেখার জন্য open করে রেখেছে। সাধারণত আপনি ভুলে যাওয়া একটি interactive shell এটি ধরে রাখে। সেটি বন্ধ করুন। সেই writer এখনও open থাকলে দ্বিতীয় connection read_only=True দিয়ে open করলেও কোনো লাভ হবে না, কারণ DuckDB একসঙ্গে একাধিক process-কে read করতে দেয় অথবা একটি process-কে write করতে দেয়, দুটো একসঙ্গে নয়।

systemctl status-এ Main process exited, code=killed, status=9/KILL। kernel memory-এর অভাবে job-টি বন্ধ করে দিয়েছে। journalctl -k | grep -i oom দিয়ে নিশ্চিত করুন, তারপর store.py-এ memory_limit কমান।

সবুজ status দেখালেও কোনো data লেখা হচ্ছে না। systemctl status, active (exited) পড়ে এবং table বড় হয়নি। feed খালি frame ফেরত দিয়েছে। এই ব্যর্থতা সবচেয়ে দেরিতে ধরা পড়ে। তাই সব ticker খালি ফেরালে job-কে non-zero exit code দিয়ে শেষ করান।

market holiday-তে timer চলেছে। systemd exchange calendar জানে না। তাই Mon-Fri-এর মধ্যে holiday-ও থাকে। run হয়, feed-এ নতুন কিছু থাকে না, এবং job-এর এটিকে error নয়, স্বাভাবিক অবস্থা হিসেবে গণ্য করা উচিত।

API থেকে 429। আপনি rate limit অতিক্রম করেছেন। Anthropic SDK নিজে থেকে backoff সহ retry করে এবং Anthropic(max_retries=5) retry-এর সংখ্যা বাড়ায়। তবু প্রতিদিন ব্যর্থ হলে job-টি একবারে অতিরিক্ত request পাঠাচ্ছে।

এটি যা নয়

এটি একটি রিসার্চ অ্যাসিস্ট্যান্ট। কোনো ফাইলিংয়ের সারাংশ তৈরি করার সময় মডেলটি সেই ফাইলিংয়ের একটি ব্যাখ্যা প্রদান করে, তবে টেক্সটে থাকা কোনো সংখ্যার ক্ষেত্রে এটি ভুল করতে পারে। এই কারণেই নোটে থাকা প্রতিটি সংখ্যা আপনার পাঠানো তথ্যের সাথে মিলিয়ে দেখা জরুরি। আউটপুটটিকে শুধুমাত্র আপনার পড়ার জন্য একটি সংক্ষিপ্ত তালিকা হিসেবে বিবেচনা করুন। এখানে কোনো আর্থিক পরামর্শ দেওয়া হয়নি এবং এটি কোনো সিগন্যাল নয়।

ব্যাকটেস্টগুলো কোনো আইডিয়াকে বাতিল করার জন্য কার্যকর, কিন্তু সেগুলোকে নিশ্চিত করার ক্ষেত্রে এগুলো দুর্বল। আপনার নিজস্ব ডেটাতে যে কৌশল ব্যর্থ হয়, তা নিশ্চিতভাবেই অকার্যকর। আর যে কৌশলটি সফল হয়, তা কেবল আপনার ডেটাতেই টিকে থাকতে পেরেছে; রাত 1টার সময় এটি যতটা বড় মনে হয়, বাস্তবে এর গুরুত্ব তার চেয়ে অনেক কম।

এক্সিকিউশন বা ট্রেড কার্যকর করা ইচ্ছাকৃতভাবেই এই আলোচনার বাইরে রাখা হয়েছে। অর্ডার এবং ব্রোকার ক্রেডেনশিয়ালের ঝুঁকির ধরন একটি রিড-অনলি রিসার্চ বক্স থেকে আলাদা। এগুলোকে একসাথে মেশালে ট্রেডিং কি (keys) এবং LLM প্রম্পট একই মেশিনে চলে আসে। আপনার সার্ভারে চালানোর মতো অন্যান্য প্রয়োজনীয় জিনিসের সাথে এই প্যাটার্নটি কোথায় অবস্থান করে তা জানতে চাইলে, সেলফ-হোস্টেড এআই এজেন্ট যা চালানো সার্থক শীর্ষক নিবন্ধটি দেখুন।

FAQ

আমার কি পেইড মার্কেট ডেটা ফিড প্রয়োজন?

প্রোটোটাইপের জন্য প্রয়োজন নেই। সিস্টেমের গঠন বোঝার জন্য একটি ফ্রি আনঅফিসিয়াল ফিড যথেষ্ট, তবে এটি যেকোনো সময় বন্ধ হয়ে যেতে পারে, কারণ এটি এমন একটি ওয়েবসাইটের ওপর নির্ভরশীল যারা আপনাকে কোনো নিশ্চয়তা দেয় না। এটি সাধারণত এক্সেপশন না দেখিয়ে খালি ফ্রেম (empty frames) রিটার্ন করে, তাই আপনার জবকে অবশ্যই রো (row) কাউন্ট চেক করতে হবে। যখন ডেটা কোনো সিদ্ধান্তের ভিত্তি হিসেবে ব্যবহৃত হতে শুরু করবে, তখন একটি ডকুমেন্টড API এবং সাপোর্ট অ্যাড্রেসসহ পেইড ফিডে চলে যান। স্টোর (store) ব্যবহারের কারণেই এই পরিবর্তনটি সহজ হয়: শুধুমাত্র ফেচ ফাংশনটি পরিবর্তন করতে হয়, আর শিডিউল, স্কিমা এবং স্ক্রিন আগের মতোই থাকে।

আমার কি প্রাইস ডেটা SQLite নাকি DuckDB-তে রাখা উচিত?

DuckDB হলো কলামার (columnar) এবং এটি অনেকগুলো রো স্ক্যান করে অ্যাগ্রিগেট করার জন্য তৈরি, যা দশ বছরের বার (bar) ডেটার মুভিং এভারেজ বের করার জন্য আদর্শ। SQLite হলো রো-অরিয়েন্টেড এবং এটি একই সময়ে একাধিক প্রসেস থেকে ছোট ছোট রিড ও রাইট করার জন্য বেশি উপযোগী। যদি একটি শিডিউলড জব কয়েকশ রো অ্যাপেন্ড করে এবং তারপর মিলিয়ন রো স্ক্যান করে, তবে DuckDB-ই ভালো পছন্দ। যদি একাধিক প্রসেসকে একই সময়ে রাইট করতে হয়, তবে WAL মোডে SQLite ব্যবহার করুন; এটি রাইটার কমিট করার সময় রিডারদের কাজ চালিয়ে যেতে দেয় এবং একটি বিজি টাইমআউট (busy timeout) সেট করলে অন্য রাইটাররা ফেইল না করে অপেক্ষা করতে পারে।

প্রতি মাসে LLM কলের খরচ কত?

প্রতিটি রেসপন্স থেকে usage.input_tokens এবং usage.output_tokens লগ করে একটি টেবিলে রাখুন, তারপর আপনার সাপ্তাহিক মোট সংখ্যাকে আপনার মডেলের প্রতি মিলিয়ন টোকেনের দাম দিয়ে গুণ করুন (যেদিন চেক করছেন সেই দিনের দাম অনুযায়ী)। এটিই একমাত্র হিসাব যা পরবর্তী কোয়ার্টারেও সঠিক থাকবে। একটি ছোট স্ক্রিনের ওপর প্রতিদিন একবার রান করলে খুব কম কল খরচ হয়, এবং নোটের দৈর্ঘ্য আপনার নিয়ন্ত্রণে থাকে: সামারি 200 শব্দের মধ্যে সীমাবদ্ধ রাখলে কম রো পাঠানোর চেয়ে বেশি সাশ্রয় হয়, কারণ প্রতিটি Claude মডেলে আউটপুট টোকেনের দাম ইনপুট টোকেনের চেয়ে বেশি।

আমার জব সফলভাবে শেষ হয়েছে কিন্তু কোনো নতুন রো লেখেনি কেন?

এর দুটি সাধারণ কারণ থাকে। মার্কেট বন্ধ ছিল, কারণ systemd Mon-Fri শিডিউলে এক্সচেঞ্জের ছুটির দিনগুলো অন্তর্ভুক্ত থাকে। অথবা ফিড প্রতিটি টিকারের জন্য একটি খালি ফ্রেম রিটার্ন করেছে, যা ক্লায়েন্ট লাইব্রেরিগুলো সাধারণত এক্সেপশন হিসেবে না দেখিয়ে শুধুমাত্র ওয়ার্নিং হিসেবে প্রিন্ট করে, ফলে প্রসেসটি 0 এক্সিট কোড দেয় এবং systemd-এ গ্রিন স্ট্যাটাস দেখায়। SELECT max(day) FROM prices-এর সাথে শেষ ট্রেডিং দিনের তুলনা করে এই পার্থক্যটি বুঝুন এবং প্রতিটি টিকার থেকে খালি ডেটা আসলে জবটিকে নন-জিরো এক্সিট কোড দিতে বাধ্য করুন।

এজেন্ট কি সিদ্ধান্ত নিতে পারে যে কী কিনতে হবে?

না, এবং এটি করার চেষ্টা করাই এই প্রজেক্টগুলোর ব্যর্থতার মূল কারণ। মডেলটির মার্কেটে কোনো অ্যাক্সেস নেই, আপনার পজিশন বা ট্যাক্স পরিস্থিতি সম্পর্কে কোনো ধারণা নেই এবং নিজের হিসাব যাচাই করার কোনো উপায় নেই। এটি যা ভালো পারে তা হলো প্রচুর টেক্সট পড়ে আপনাকে জানানো যে আজ কোন বিষয়গুলোতে আপনার মনোযোগ দেওয়া উচিত। এটি যা লেখে তার কোনোটিই আর্থিক পরামর্শ নয় এবং সিদ্ধান্ত নেওয়ার দায়িত্ব আপনারই।

এই লেখাটি কি কাজে লেগেছে?