Paano Gumawa ng Stock Research Agent sa VPS
Alamin ang setup ng self-hosted stock research agent gamit ang Python, DuckDB, systemd timer, market data feed, at Claude API para sa scheduled screening.
Ano ang isang self-hosted stock research agent
Ang self-hosted stock research agent ay isang maliit na program na tumatakbo sa server na pagmamay-ari mo. Kumukuha ito ng market data ayon sa iskedyul, nag-iimbak nito sa isang local database, nagsasagawa ng screening dito, at humihingi sa isang large language model (LLM) ng buod ng mga nagbago. Nagbabasa at nagsasala lamang ito. Hindi ito nagta-trade, at walang anumang bahagi ng gabay na ito ang maituturing na financial advice.
Dalawang taong bubuo nito mula sa simula ay maaaring pumili ng magkakaibang library ngunit mapupunta pa rin sa parehong apat na bahagi: isang feed na nagbibigay ng prices at fundamentals, isang local store na nag-iingat ng bawat row na nakuha mo, isang job na nagre-refresh sa store ayon sa timer, at isang LLM layer na ginagawang mga pangungusap ang natitirang rows. Binubuo ng gabay na ito ang ganitong setup gamit ang Python, DuckDB, isang systemd timer, at Claude API (application programming interface). Isa itong narrow agent na may fixed loop. Kung mas nais mong unahin ang pangkalahatang bersyon, tinatalakay ng staged path para matutuhan ang AI agents ang loop, tools, at memory nang sunod-sunod bago ito maging isang domain problem. Hiwalay na job ang execution at iba ang failure modes nito. Mas angkop itong ilagay sa isang VPS na naka-setup para sa trading bots.
Ang apat na bahagi at ang ginagawa ng bawat isa
Ang feed ang tanging bahaging nakikipag-ugnayan sa labas na mundo. Alam nito kung paano humingi ng ticker at date range, at kung paano magbalik ng mga row. Lahat ng downstream component ay nagbabasa mula sa database mo sa halip na sa feed, kaya ang pagkawala ng feed ay magdudulot lamang ng isang araw na walang bagong data, hindi ng sirang screen.
Ang store ang pangunahing layunin ng buong proseso. Karaniwang maaaring kunin muli sa ibang pagkakataon ang daily close na hindi mo naitala. Ngunit hindi na karaniwang maibabalik ang intraday quote, estimate bago ito baguhin, o fundamentals figure bago ito i-restatement. Sa pamamagitan ng store, nakakabuo ka ng tala ng aktuwal na sinabi ng data noong araw na iyon.
Ang scheduler ang nagpapasya kung kailan magaganap ang refresh. Sa isang server, systemd timer ito. Ito ang dahilan kung bakit mas mahalaga rito ang VPS kaysa sa code.
Ang LLM layer ay nagbabasa ng maikling text block na ginawa ng iyong SQL at sumusulat ng buod nito. Hindi ito kailanman kumokonekta sa database at hindi rin ito bumubuo ng query. Kung ang model ang sumusulat ng SQL, ang isang maling token ay maaaring maging maling numero sa loob ng maayos na pangungusap, nang walang maihahambing na reference. Kung SQL ang gumagawa ng mga numero, ang mali lamang na magagawa ng model ay ang prose, at maaari mong suriin ang prose laban sa mga row na ipinadala mo.
Bakit patakbuhin ito sa VPS sa halip na sa laptop
Ang scheduler ang dahilan. Ang pagsasara ng US market sa 16:00 New York time ay 22:00 sa Berlin at 04:00 kinabukasan sa Jakarta. Naka-sleep ang laptop sa dalawang oras na iyon. Mas magastos ang isang missed run kaysa sa late na abiso: karaniwang maaaring i-fetch muli sa ibang pagkakataon ang daily bars, pero hindi na maibabalik ang anumang na-revise, kaya permanente ang puwang sa iyong record. Pareho ang batayan para sa anumang agent na kailangang manatili ang schedule at stored state nito kahit mag-reboot, na siyang layunin ng pagpapatakbo ng KiroCrew bilang always-on agent sa sarili mong VPS.
Mas maliit ang ikalawang dahilan, pero mahalaga pa rin. Naglalaman ang server ng isang API key sa isang file, na pagmamay-ari ng isang system user na walang login shell at ginagamit ng isang job. Mas mahirap ayusin ang ganitong setup sa laptop na ginagamit mo rin sa pag-browse sa web. Panatilihing wala ang key sa code at sa input ng model. Ito ang paksa ng pag-iwas na mailagay ang API keys sa AI agent.
Pag-size nito: disk, RAM, at tokens
Ang disk ang madaling bahagi. Ang isang daily bar ay isang row para sa bawat ticker sa bawat trading day, at ang isang trading year sa US ay humigit-kumulang 252 araw.
Ang datos sa likod ng tsart na ito
[
{
"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"
}
]Ang dalawampung ticker ay magiging 5,040 rows pagkalipas ng isang taon. Ang linyang 500 tickers ay aabot sa 1,260,000 rows pagkalipas ng sampung taon. Ang bawat row ay may petsa at ilang double, at naka-compress ang mga column na ini-store ng DuckDB, kaya sampu-sampung megabytes ito sa halip na gigabytes. Huwag basta magtiwala sa estimate na ito, kasama na ang ginawa ko. Patakbuhin ang du -h /opt/research/data/market.duckdb pagkatapos ng unang backfill at gamitin ang sarili mong numero.
Dito nagiging limitasyon ang RAM sa isang maliit na VPS. Bilang default, kumukuha ang DuckDB ng malaking bahagi ng memory ng machine at ng lahat ng core nito para sa isang query. Tama ito sa isang analytics server, pero hindi sa isang 2 GB box na nagpapatakbo rin ng iba pang serbisyo. Kapag nagpatakbo ka ng isang aggregation sa buong prices table, maaaring patayin ng kernel ang process, at iuulat ng systemd ang Main process exited, code=killed, status=9/KILL habang ipinapakita naman ng journalctl -k ang out of memory kill. Itakda nang tahasan ang memory_limit at threads upang bumagal ang query sa halip na mamatay.
Dapat sukatin ang tokens, hindi tantiyahin. May usage object ang bawat Messages API response na may input_tokens at output_tokens. Isulat ang dalawang ito sa isang table sa bawat call. Pagkalipas ng isang linggo, alam mo na ang aktuwal mong volume, at imu-multiply mo ito sa presyong inililista ng model sa araw na mag-check ka. May dalawang bagay na sapat na stable para pagbatayan ng pagpaplano. Mas mataas ang presyo ng output tokens kaysa input tokens sa bawat Claude model, kaya mas malaki ang epekto sa bill ng paglilimita sa note sa 200 salita kaysa sa pagbawas sa data na ipinapadala mo. At hindi nakatutulong ang prompt caching sa job na isang beses lang tumatakbo bawat araw. Bilang default, 5 minuto ang tagal ng cache, at ang pinakamahabang lifetime na iniaalok ng Anthropic ay 1 oras. Nagkakahalaga itong isulat ng 2x ng base input price sa halip na 1.25x para sa 5 minutong cache. Sa alinmang paraan, expired na ang cached block bago ang run sa susunod na araw, kaya babayaran mong muli ang buong input price. Sulit ang caching kapag maraming call ang ginagawa ng isang run gamit ang parehong malaking block ng text.
I-install ang mga bahagi
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/dataSuriin ang installation bago magsulat ng anumang code:
sudo -u research /opt/research/venv/bin/python -c 'import duckdb, yfinance, anthropic; print("ok")'Ini-print nito ang ok. Kung nilaktawan mo ang virtual environment at pinatakbo ang pip install laban sa system Python, pipigilan ka ng Ubuntu 24.04 gamit ang error: externally-managed-environment, dahil pagmamay-ari ng distribution ang /usr/lib/python3 at hindi nito pinapayagang doon magsulat ang pip. Hindi lang kagandahang-asal ang venv. Ito ang tanging directory na maaaring galawin ng pip.
Ilagay ang API key sa isang file na mababasa ng service user at walang ibang makakabasa:
sudo install -d -m 755 /etc/research
sudo install -m 640 -o root -g research /dev/null /etc/research/env
sudoedit /etc/research/envMaglagay ng isang linya rito, na walang quotes at walang export, dahil systemd mismo ang nagpa-parse sa file na ito sa halip na ipasa ito sa isang shell:
ANTHROPIC_API_KEY=sk-ant-your-key-hereAng store: dalawang table
# /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 conAng primary key sa (ticker, day) ang dahilan kung bakit ligtas ulitin ang refresh. Pinapalitan ng INSERT OR REPLACE ang row na mayroon na para sa ticker at araw na iyon, kaya hindi nadodoble ang table kapag pinatakbo muli ang backfill. Kung wala ang key, tahimik na magdo-duplicate ang bawat bar kapag inulit ang proseso matapos ang crash, at magiging mali ang bawat average na kukuwentahin mo pagkatapos nito nang walang error message na magsasabi kung ano ang nangyari.
Pinapayagan ng DuckDB na isang process lamang ang magbukas ng file para sa pagsusulat. Agad na mabibigo ang pangalawang writer gamit ang Could not set lock on file, na sinusundan ng PID ng process na may hawak nito. Sa aktuwal na paggamit, karaniwan itong ang interactive na duckdb shell na naiwan mong bukas sa ibang terminal. Hindi ito nalalampasan ng read-only connection. Inilalarawan ng concurrency docs ng DuckDB ang dalawang mode: isang process na nagbabasa at nagsusulat, o maraming process na nagbabasa lamang; hindi maaaring pagsabayin ang dalawang mode. Tumatanggap ang connect ng read_only flag upang makapagbahagi ng file ang maraming reader, ngunit gagana lamang ito habang walang writer na nakabukas dito. Kung talagang kailangang magsulat ng maraming process nang sabay, ibang database engine ang dapat gamitin: sa WAL mode ng SQLite, maaaring magbasa ang mga reader habang nagco-commit ang isang writer, at pinaghihintay ng busy timeout ang ibang writer sa halip na agad mabigo. Inihahambing ng DuckDB kumpara sa SQLite para sa server workload ang dalawa, at tinatalakay sa pagpapatakbo ng SQLite sa production sa isang VPS ang mga setting para gumana nang tama ang WAL.
Ang refresh job
# /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()Manually itong patakbuhin nang isang beses:
sudo -u research /opt/research/venv/bin/python /opt/research/refresh.pySa unang run, isi-store nito ang data mula sa mga nakaraang taon at magpi-print ng isang linya bawat ticker, na may bilang na nasa libo. Patakbuhin itong muli makalipas ang isang minuto. Dapat magpakita ang bawat linya ng 1 o 2 row dahil nagsisimula ang job sa huling araw na naka-store na. Ang ikalawang run ang aktuwal na test: kung nasa libo pa rin ang mga bilang, walang ibinabalik ang max(day) at nire-rewrite ng insert ang buong history mo gabi-gabi.
Ang check na df.empty ang pinakamahalagang linya sa file. Hindi nagra-raise ang Ticker.history() kapag mali o na-delist ang symbol. Nagpi-print ito ng warning na posibleng na-delist ang symbol at walang nakitang price data; nagbabago ang eksaktong wording depende sa library version. Nagbabalik din ito ng walang laman na DataFrame. Kapag wala ang check na ito, walang naisusulat ang job, nag-e-exit ito gamit ang 0, at ipinapakita ng systemd na healthy at green ang run habang tahimik na tumitigil ang paglaki ng table. Malalaman mo lang ito makalipas ang ilang linggo, kapag pare-pareho na ang mga row na ibinabalik ng screen araw-araw.
May dahilan din ang pag-handle sa timestamp. Maaaring may exchange timezone ang index na ibinabalik ng feed. Hindi katumbas ng pag-convert sa UTC ang pagtanggal ng offset. Kapag ang Tokyo session ay may timestamp na midnight sa lokal na oras, mapupunta ito sa nakaraang calendar day kapag kino-convert sa UTC. Dahil dito, tahimik na naiuusog ng UTC conversion nang isang araw pabalik ang bawat Japanese bar at nasisira ang primary key. Pinananatili ng tz_localize(None) ang sariling session date ng exchange, na siyang kahulugan ng isang daily bar.
Ang timer na tumatakbo sa pagsasara ng market
Nagsasara ang US market sa 16:00 ayon sa oras ng New York, at inaabot ng ilang minuto bago ma-settle ang mga huling print. Kaya tumatakbo ang job sa 16:20. Isulat ito sa oras ng New York, hindi sa UTC. Ang New York ay UTC minus 5 kapag winter at UTC minus 4 kapag summer. Kaya ang timer na gumagamit ng fixed UTC hour ay umaatras o sumusulong ng isang oras dalawang beses bawat taon at nagsisimulang tumakbo bago magsara ang market. Tumatanggap ang systemd 235 at mas bago ng timezone nang direkta sa OnCalendar, at ang Ubuntu 24.04 ay may kasamang 255. Kumpirmahin ang version na gamit mo sa 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.targetAng Type=oneshot lamang ang service type na tumatanggap ng higit sa isang ExecStart. Pinapatakbo nito ang mga ito ayon sa pagkakasunod-sunod at humihinto kapag may nag-exit na may non zero status. Iyan mismo ang kinakailangang behavior: kapag nabigo ang refresh, hindi dapat agad sundan ito ng pag-display ng screen na may lumang data. Mahalaga ang Persistent=true sa isang VPS na nagre-reboot para sa mga kernel update. Kung magre-reboot ito sa 16:15 nang wala nito, tuluyang mawawala ang run. Kapag mayroon nito, tatakbo ang job sa sandaling bumalik ang machine. Ang agent na kailangang manatiling aktibo sa halip na tumakbo at mag-exit ay nangangailangan ng kabaligtarang unit structure: isang long lived process na may mga Restart= rule at walang timer. Ito ang ipinapaliwanag sa pagpapatakbo ng dsh nang headless sa isang VPS gamit ang systemd.
sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timerDapat magpakita ang list-timers ng column na NEXT na naglalaman ng susunod na weekday run, na iko-convert sa sariling local time ng server. Ang walang laman na listahan ay nangangahulugang hindi enabled ang timer o nawawala ang [Install] section ng unit. Dahil dito, walang mai-link ang enable sa timers.target.
Ang screen: SQL muna, model sa huli
Eksakto ang SQL at walang gastos sa bawat run. Hindi ganoon ang model. Kaya nililimitahan muna ng query ang mga posibleng resulta, at ang mga natitirang row lamang ang ipinapadala sa model. I-save ito bilang /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;Hindi dekorasyon ang n > 50 filter. Kinukuwenta ng ROWS BETWEEN 49 PRECEDING AND CURRENT ROW ang average ng anumang row na mayroon, kaya sa ikatlong row ng isang ticker, average ng tatlong araw ang ibinabalik nito at tinatawag pa rin itong ma50. Kapag ikinumpara ito sa ma20, makagagawa ka ng crossover sa simula ng history ng bawat ticker kahit hindi naman ito nangyari. Kapag nag-filter batay sa row number, inaalis ang mga row kung saan hindi pa kumpleto ang window.
Ano ang nakikita ng model, at ano ang hindi nito kailanman nakikita
# /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()Mahalaga ang linyang thinking sa model na ito. Bilang default, nag-iisip ang Claude Sonnet 5. Sinisingil bilang output ang mga thinking token nito at ibinibilang ang mga ito sa max_tokens. Bumabalik ang mga ito bilang isang block bago ang text. Kung wala ang linyang iyon, maaaring ang thinking block ang resp.content[0], at mabibigo ang .text lookup. Legacy model na ngayon ang Claude Sonnet 5. Tinatanggihan ng successor nito na claude-sonnet-5-5 ang disabled. Ang setting na dapat gamitin doon ay thinking={'type': 'between_tools'}, na nag-o-off ng thinking bago ang sagot.
Nililimitahan ng word limit sa system prompt ang mas magastos na bahagi ng bill. Ang instruction na gamitin lamang ang mga ibinigay na row ang kailangan mong i-verify, hindi basta pagkatiwalaan: mag-delete ng isang column sa block, patakbuhin itong muli, at basahin ang output. Kung lumilitaw pa rin ang value para sa column na iyon, pinunan ng model ang puwang, at hindi sapat na mahigpit ang prompt mo. Dalawang minuto lamang ang kailangan sa test na ito, at ito lamang ang tapat na paraan para malaman ang resulta.
Hindi kailanman nakikita ng model ang API key, database path, o query. Tumatanggap ito ng mga row at nagbabalik ng prose. Ang boundary na ito ang dahilan kung bakit mache-check ang output, dahil dapat lumitaw din sa block na ipinadala mo ang bawat number sa note, at maikukumpara mo ang mga ito line by line. Sa sandaling palawakin mo ang input upang isama ang mga page na kinuha mula sa web, nagbabago ang boundary na ito. Maaaring maglaman ang isang headline o forum post ng mga instruction para sa model, sa halip na facts para sa iyo. Ito ang surface na binubuksan ng pagbibigay sa isang agent ng SearXNG search backend. Para sa higit pang impormasyon tungkol sa prompting, mas malalim na tinatalakay ng paggamit ng Claude para sa financial analysis kung ano ang mahusay basahin ng model.
Isang run, mula simula hanggang dulo
Sa 16:20 New York time, sinisimulan ng timer ang service. Kinukuha ng refresh.py ang feed para sa bawat ticker simula sa huling naka-store na araw, nagsusulat ng isa o dalawang bagong bar para sa bawat isa, at nagpi-print ng isang linya bawat ticker. Binubuksan ng screen.py ang parehong file, pinapatakbo ang moving average query, at tumatanggap ng ilang row. Nagiging text block na may ilang daang token ang mga row na iyon. Ginagawang maikling note ng isang API call ang mga ito, ipinapadala ang note sa journal, at may isang row na naitatala sa runs kasama ang bilang ng mga token at bilang ng mga hit.
journalctl -u research-refresh.service -n 50 --no-pagerAng maayos na log ay may isang linya bawat ticker, kasunod ang note, at pagkatapos ang research-refresh.service: Deactivated successfully. Kung binabasa mo ang log na iyon mula sa isang Claude Code session sa server habang may ibang session na gumagawa sa screen query, maaaring magpasa ng text ang dalawang session sa isa’t isa sa halip na ikaw ang magkopya ng mga linya sa pagitan ng mga terminal. Pagkalipas ng isang linggo, kunin ang sarili mong gastos mula sa database:
SELECT count(*) AS runs,
sum(input_tokens) AS in_tokens,
sum(output_tokens) AS out_tokens
FROM runs;I-multiply ang mga kabuuang iyon sa per million token prices na inililista ng iyong model sa araw na binabasa mo ang mga ito. Iyan ang magbibigay sa iyo ng aktuwal na halaga sa halip na tantiya ng ibang tao. Nagbabago ang published prices. Hindi nagbabago ang arithmetic.
Bakit nag-o-overfit ang mga backtest, at paano ito mapapanood habang nangyayari
Isulat muli ang screen bilang function ng dalawang window length, i-sweep ang grid ng mga pair, at i-rank ang mga ito ayon sa return. Magmumukhang napakahusay ng pinakamagandang pair. Iyan ang problema, hindi ang resulta. Ang grid na may 200 pair ay 200 eksperimento, at pinili mo ang pinakasuwerte.
Mapapanood mo itong mangyari sa loob ng sampung minuto. Hatiin sa dalawa ang store ayon sa petsa. I-sweep ang grid sa unang bahagi lamang at itala ang nanalo. I-sweep ang parehong grid sa ikalawang bahagi. Kung malaki ang agwat ng dalawang nanalo, nagfi-fit sa noise ang mga parameter, at walang sinasabi tungkol sa bukas ang isang pair na nananalo lamang sa bahaging pinag-tune-an mo rito.
Mas malala ang survivorship kaysa overfitting dahil hindi ito maaayos ng tuning. Ang ticker list mo ay mga kasalukuyang miyembro ng index, kaya naglalaman lamang ito ng mga kumpanyang nakaligtas. Kapag hiningi mo sa feed ang ticker na na-delist noong 2019, magbabalik ito ng empty frame. Ibig sabihin, hindi kailanman nakapasok ang kumpanyang iyon sa store at sa test mo. Naibukod na ang mga natalo sa bawat backtest na pinapatakbo mo.
Sinisira ng restated fundamentals ang timeline. Ang revenue figure na ibinabalik ng API ngayon para sa isang quarter noong 2019 ay hindi laging kapareho ng figure na inilathala noong 2019. Gumagamit ang screen na naghahalo ng fundamentals ngayon at mga presyo noong 2019 ng impormasyong hindi pa umiiral noon. Karaniwang ligtas ang mga presyo rito. Karaniwang hindi ligtas ang fundamentals.
Nagbabago ang adjusted prices habang ginagamit mo ang mga ito. Sa auto_adjust=True, ina-adjust paatras ang mga closing price para sa dividends at splits, kaya bahagyang naiibang history ang ibinabalik ng parehong query kapag pinatakbo sa susunod na buwan. Ang pag-store sa mga row na aktuwal mong ginamit ang dahilan kung bakit reproducible ang resulta. Isa rin ito sa mga dahilan kung bakit umiiral ang local store.
Hindi rin isinasaalang-alang ng backtest ang commissions at slippage, at ipinapalagay nitong hindi gagalaw ang presyo dahil sa order mo. Bahagi ang mga iyon ng execution, na wala sa scope dito at saklaw sa pagpapatakbo ng trading bots sa isang VPS.
Mga failure mode at mga string na makikita mo
error: externally-managed-environment kapag nagpapatakbo ang pip. Nasa labas ka ng virtual environment. Tawagin ang /opt/research/venv/bin/pip gamit ang buong path.
Could not set lock on file, na may PID pagkatapos nito. May ibang process na nakabukas para sa pagsusulat sa DuckDB file, karaniwang interactive shell na nakalimutan mong isara. Isara ito. Hindi makakatulong ang pagbukas ng ikalawang connection gamit ang read_only=True habang bukas pa ang writer na iyon, dahil pinapayagan ng DuckDB ang maraming process na magbasa o isang process na magsulat, pero hindi ang dalawang ito nang sabay.
Main process exited, code=killed, status=9/KILL sa systemctl status. Pinatay ng kernel ang job dahil sa kakulangan ng memory. Kumpirmahin gamit ang journalctl -k | grep -i oom, pagkatapos ay ibaba ang memory_limit sa store.py.
Isang berdeng run na walang sinusulat. Binabasa ng systemctl status ang active (exited) at hindi lumalaki ang table. Walang laman ang mga frame na ibinalik ng feed. Pinakamahirap makita ang failure na ito, kaya dapat mag-exit ang job na may non-zero status kapag walang laman ang ibinalik para sa bawat ticker.
Tumakbo ang timer sa market holiday. Hindi alam ng systemd ang calendar ng exchange, kaya kasama sa Mon-Fri ang mga holiday. Tumatakbo ang job, walang bagong data ang feed, at dapat ituring ito ng job na normal sa halip na error.
429 mula sa API. Lumampas ka sa rate limit. Awtomatikong nagre-retry ang Anthropic SDK gamit ang backoff, at pinapataas ng Anthropic(max_retries=5) ang bilang ng mga pagtatangka. Kung patuloy pa rin itong nagfa-fail araw-araw, masyadong maraming hinihingi ang job sa isang burst.
Hindi ito
Isa itong research assistant. Ang model na nagbubuod ng isang filing ay gumagawa ng pagbasa sa filing na iyon, at maaari itong may kumpiyansang magkamali tungkol sa numerong naka-print sa teksto. Kaya dapat masundan ang bawat figure sa note pabalik sa row na ipinadala mo. Ituring ang output bilang shortlist ng mga bagay na ikaw mismo ang babasahin. Walang financial advice dito, at wala sa mga ito ang signal.
Kapaki-pakinabang ang backtests para tanggihan ang mga ideya, pero mahina ang mga ito sa pagkumpirma sa mga iyon. Kung bumagsak ang isang strategy sa sarili mong data, talagang patay na ito. Kung pumasa ito, nakaligtas lamang ito sa sarili mong data. Mas maliit na pahayag ito kaysa sa pakiramdam nito kapag 1am na.
Sadyang wala sa saklaw dito ang execution. Iba ang risk profile ng orders at broker credentials sa isang read-only research box. Kapag pinaghalo ang mga ito, napupunta ang trading keys sa parehong machine na pinoproseso ang isang LLM prompt. Kung gusto mong makita kung saan nakapuwesto ang pattern na ito kaugnay ng iba pang mga bagay na sulit patakbuhin sa sarili mong server, ang mga self-hosted AI agent na sulit patakbuhin ang mas malawak na gabay.
FAQ
Kailangan ko ba ng bayad na market data feed?
Hindi para sa prototype. Ayos ang libreng unofficial feed para matutuhan ang istruktura ng system, at masisira ito dahil nakadepende ito sa website na walang obligasyong magbigay sa iyo ng anuman. Karaniwan itong nasisira bilang mga empty frame, hindi bilang exception, kaya dapat mag-check ang job mo ng row counts. Lumipat sa bayad na feed na may documented API at support address kapag ginagamit na ang data sa isang desisyon. Ang store ang nagpapamura sa paglipat na ito: fetch function lang ang kailangang baguhin, habang nananatiling pareho ang schedule, schema, at screen.
Dapat ko bang itago ang mga presyo sa SQLite o DuckDB?
Columnar ang DuckDB at ginawa ito para mag-scan ng maraming row upang mag-compute ng aggregate. Ito mismo ang kailangan para sa moving average ng bars sa loob ng sampung taon. Row-oriented ang SQLite at mas angkop sa maraming maliliit na read at write mula sa ilang process nang sabay-sabay. Para sa isang scheduled job na nag-a-append ng ilang daang row at pagkatapos ay nagso-scan ng milyun-milyong row, mas angkop ang DuckDB. Kung kailangang magsulat nang sabay-sabay ang ilang process, hinahayaan ng SQLite sa WAL mode na magpatuloy ang mga reader habang nagco-commit ang isang writer. Dahil dito, maaaring maghintay ang ibang writer sa busy timeout sa halip na tuluyang mag-fail.
Magkano ang magiging gastos ng LLM calls bawat buwan?
I-log ang usage.input_tokens at usage.output_tokens mula sa bawat response sa isang table. Pagkatapos, i-multiply ang weekly totals sa presyo bawat milyong token na inililista ng model mo sa araw ng pag-check. Iyon lang ang halagang mananatiling tama sa susunod na quarter. Ang isang daily run sa maikling screen ay maliit na bilang ng calls. Ang haba ng note ang bahaging makokontrol mo: mas malaki ang matitipid sa pag-cap ng summary sa 200 words kaysa sa pagbawas ng bilang ng rows na ipinapadala, dahil mas mataas ang presyo ng output tokens kaysa input tokens sa bawat Claude model.
Bakit naging successful ang job ko pero walang bagong row na naisulat?
May dalawang karaniwang sanhi. Sarado ang market dahil kasama sa systemd Mon-Fri schedule ang mga exchange holiday. O nagbalik ang feed ng empty frame para sa bawat ticker. Madalas itong iniuulat ng client libraries bilang printed warning sa halip na exception, kaya nag-e-exit pa rin ang process sa 0 at ipinapakita pa rin ng systemd na successful ang run. Tukuyin ang sanhi sa pamamagitan ng paghahambing ng SELECT max(day) FROM prices sa huling totoong trading day. Gawing non-zero ang exit status ng job kapag empty ang resulta para sa lahat ng ticker.
Maaari bang magpasya ang agent kung ano ang bibilhin?
Hindi. Dito nagkakamali ang mga project kapag ginawa itong subukang magpasya. Walang market access ang model, wala itong impormasyon tungkol sa position o tax situation mo, at wala itong paraan upang i-check ang sarili nitong mga numero laban sa anumang source. Ang mahusay nitong gawin ay magbasa ng maraming text at tukuyin kung aling ilang item ang dapat mong bigyang-pansin ngayong araw. Walang financial advice sa anumang isinusulat nito. Ang desisyon, pati ang pananagutan dito, ay nananatili sa iyo.