Tự host agent nghiên cứu cổ phiếu trên VPS
Tự dựng agent nghiên cứu cổ phiếu trên VPS với market data feed, DuckDB, systemd timer chạy lúc đóng cửa thị trường và LLM screen qua Claude API.
Khái niệm agent nghiên cứu cổ phiếu tự host
Agent nghiên cứu cổ phiếu tự host là một chương trình nhỏ chạy trên server do bạn sở hữu. Chương trình định kỳ lấy dữ liệu thị trường, lưu dữ liệu vào cơ sở dữ liệu local, lọc dữ liệu rồi yêu cầu một large language model (LLM) viết bản tóm tắt về những thay đổi. Nó chỉ đọc và lọc dữ liệu. Nó không giao dịch, và không nội dung nào trong guide này là lời khuyên tài chính.
Hai người tự xây dựng hệ thống này từ đầu có thể chọn các library khác nhau nhưng vẫn tạo ra cùng bốn phần: một feed cung cấp giá và dữ liệu nền tảng, một local store lưu mọi row bạn đã lấy, một job refresh store theo lịch, và một lớp LLM chuyển các row còn lại thành câu mô tả. Guide này xây dựng mô hình đó bằng Python, DuckDB, systemd timer và Claude API (application programming interface). Đây là một agent chuyên biệt với loop cố định. Nếu bạn muốn tìm hiểu phiên bản tổng quát trước, lộ trình từng bước để học về AI agent trình bày lần lượt loop, tool và memory trước khi chúng trở thành một bài toán theo domain. Việc thực thi là một job riêng với các failure mode riêng. Phần đó nên đặt trên một VPS được cấu hình cho trading bot.
Bốn phần và chức năng của từng phần
Feed là phần duy nhất giao tiếp với bên ngoài. Nó biết cách yêu cầu dữ liệu ticker và khoảng thời gian, rồi trả về các dòng dữ liệu. Tất cả thành phần phía sau đọc database thay vì đọc feed. Vì vậy, khi feed ngừng hoạt động, bạn chỉ mất dữ liệu mới của một ngày thay vì làm hỏng toàn bộ màn hình.
Store là mục đích chính của toàn bộ quy trình. Giá đóng cửa theo ngày chưa được ghi lại thường vẫn có thể lấy lại sau. Nhưng báo giá intraday, một giá trị ước tính trước khi được điều chỉnh, hoặc một số liệu fundamentals trước khi được restate thì không thể lấy lại chính xác. Store giúp bạn xây dựng bản ghi về những gì dữ liệu thực sự cho biết vào đúng ngày đó.
Scheduler quyết định thời điểm refresh. Trên server, đó là systemd timer. Vì vậy, VPS quan trọng hơn code trong phần này.
LLM layer đọc một block văn bản ngắn do SQL tạo ra rồi viết bản tóm tắt từ đó. Nó không bao giờ kết nối đến database và cũng không tự xây dựng query. Nếu model viết SQL, chỉ một token sai cũng biến thành một con số sai trong một câu văn trôi chảy, mà không có dữ liệu đối chiếu. Nếu SQL tạo ra các con số, model chỉ có thể viết sai phần diễn đạt, và bạn có thể kiểm tra phần diễn đạt đó với các dòng dữ liệu đã gửi.
Tại sao nên chạy trên VPS thay vì laptop
Scheduler là lý do chính. Thị trường Mỹ đóng cửa lúc 16:00 theo giờ New York, tức 22:00 ở Berlin và 04:00 sáng hôm sau ở Jakarta. Laptop sẽ ở trạng thái ngủ vào cả hai thời điểm đó. Bỏ lỡ một lần chạy gây thiệt hại lớn hơn một ghi chú đến muộn: dữ liệu theo ngày thường có thể tải lại sau, nhưng dữ liệu đã bị sửa thì không thể khôi phục phiên bản cũ, nên khoảng trống trong bản ghi của bạn sẽ là vĩnh viễn. Lý do tương tự áp dụng cho mọi agent cần duy trì lịch chạy và state đã lưu qua các lần reboot. Đó là mục tiêu của việc chạy KiroCrew dưới dạng agent luôn hoạt động trên VPS của riêng bạn.
Lý do thứ hai nhỏ hơn nhưng vẫn đáng kể. Server lưu một API key duy nhất trong một file duy nhất. File này thuộc về một system user duy nhất, không có login shell và chỉ được một job sử dụng. Cách này khó thực hiện hơn nhiều trên laptop mà bạn cũng dùng để duyệt web. Giữ key bên ngoài code và bên ngoài input của model. Đây là nội dung của việc giữ API key ngoài AI agent.
Ước tính dung lượng: disk, RAM và token
Disk là phần dễ ước tính. Mỗi thanh giá hằng ngày là một row cho mỗi mã trong mỗi ngày giao dịch, và một năm giao dịch tại Mỹ có khoảng 252 ngày.
Dữ liệu đằng sau biểu đồ này
[
{
"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"
}
]Hai mươi mã sẽ có 5,040 row sau một năm. Dòng 500 tickers đạt 1,260,000 row sau mười năm. Mỗi row gồm một ngày và một số giá trị double, còn DuckDB lưu các column ở dạng đã nén, nên dung lượng chỉ ở mức hàng chục megabyte thay vì gigabyte. Không nên tin tuyệt đối vào ước tính này, kể cả ước tính của tôi. Hãy chạy du -h /opt/research/data/market.duckdb sau lần backfill đầu tiên và dùng số liệu của chính bạn.
RAM mới là phần gây vấn đề trên một VPS nhỏ. Theo mặc định, DuckDB sử dụng phần lớn memory của máy và toàn bộ core cho một query, phù hợp với analytics server nhưng không phù hợp với máy 2 GB còn chạy các dịch vụ khác. Khi đó, một phép aggregation trên toàn bộ bảng prices có thể khiến kernel kill process, còn systemd ghi nhận Main process exited, code=killed, status=9/KILL và journalctl -k hiển thị việc process bị kill do hết memory. Hãy đặt rõ memory_limit và threads để query chạy chậm hơn thay vì bị kill.
Nên đo token thay vì ước tính. Mỗi response từ Messages API đều có object usage với input_tokens và output_tokens. Ghi cả hai giá trị vào một table trong mỗi lần gọi. Sau một tuần, bạn sẽ biết volume thực tế và có thể nhân với mức giá mà model công bố tại thời điểm kiểm tra. Có hai điểm đủ ổn định để lập kế hoạch. Output token luôn có giá cao hơn input token trên mọi Claude model, nên giới hạn note ở 200 từ giúp giảm chi phí nhiều hơn so với việc cắt bớt data gửi đi. Prompt caching cũng không giúp ích cho job chạy mỗi ngày một lần. Cache mặc định tồn tại 5 phút, còn thời gian dài nhất Anthropic cung cấp là 1 giờ. Thời gian này có giá ghi cache bằng 2x giá input cơ bản, thay vì 1.25x đối với cache 5 phút. Dù theo cách nào, đến lần chạy của ngày hôm sau, block được cache đã hết hạn và bạn lại phải trả đầy đủ giá input. Caching có lợi khi một lần chạy thực hiện nhiều call trên cùng một block văn bản lớn.
Cài đặt các thành phần
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/dataKiểm tra quá trình cài đặt trước khi viết code:
sudo -u research /opt/research/venv/bin/python -c 'import duckdb, yfinance, anthropic; print("ok")'Lệnh này in ra ok. Nếu bỏ qua virtual environment và chạy pip install với system Python, Ubuntu 24.04 sẽ dừng với error: externally-managed-environment, vì bản phân phối quản lý /usr/lib/python3 và không cho pip ghi vào đó. venv không phải là bước làm cho có. Đây là thư mục duy nhất pip được phép ghi.
Đặt API key vào một file mà service user có thể đọc nhưng không user nào khác có quyền đọc:
sudo install -d -m 755 /etc/research
sudo install -m 640 -o root -g research /dev/null /etc/research/env
sudoedit /etc/research/envGhi một dòng vào file đó, không có dấu ngoặc kép và không có export, vì systemd tự phân tích file này thay vì truyền file cho shell:
ANTHROPIC_API_KEY=sk-ant-your-key-hereStore: hai bảng
# /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 conKhóa chính trên (ticker, day) là yếu tố giúp chạy lại thao tác refresh an toàn. INSERT OR REPLACE ghi đè một row đã tồn tại cho ticker và ngày đó, nên chạy backfill hai lần không làm nhân đôi dữ liệu trong bảng. Nếu không có khóa này, một lần chạy lại sau khi bị crash sẽ âm thầm tạo bản sao cho mọi bar, khiến mọi giá trị trung bình tính sau đó đều sai mà không có thông báo lỗi nào để phát hiện.
DuckDB chỉ cho phép một process mở file để ghi tại một thời điểm. Writer thứ hai sẽ fail ngay với Could not set lock on file, kèm theo PID của process đang giữ file; trên thực tế, đó thường là shell duckdb tương tác mà bạn quên mở trong một terminal khác. Kết nối chỉ đọc cũng không giải quyết được vấn đề này. Tài liệu về concurrency của DuckDB mô tả 2 chế độ: một process vừa đọc vừa ghi, hoặc nhiều process chỉ đọc; không thể kết hợp 2 chế độ này. connect nhận một flag read_only để nhiều reader có thể dùng chung file, nhưng chỉ hoạt động khi không có writer nào đang mở file. Nếu nhiều process thực sự cần ghi cùng lúc, đó là trường hợp nên dùng engine khác: SQLite ở WAL mode cho phép reader hoạt động trong khi một writer commit, còn busy timeout khiến các writer khác chờ thay vì fail. DuckDB và SQLite cho workload trên server so sánh 2 engine này, còn chạy SQLite trên production trên VPS trình bày các setting giúp WAL hoạt động đúng.
Job cập nhật
# /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()Chạy thủ công một lần:
sudo -u research /opt/research/venv/bin/python /opt/research/refresh.pyLần chạy đầu tiên sẽ bổ sung dữ liệu cho nhiều năm và in một dòng cho mỗi mã, với số lượng lên đến hàng nghìn. Chạy lại sau một phút. Khi đó, mỗi dòng chỉ hiển thị 1 hoặc 2 bản ghi vì job bắt đầu từ ngày cuối cùng đã được lưu. Lần chạy thứ hai mới là bài kiểm tra thực tế: nếu số lượng vẫn lên đến hàng nghìn, max(day) không trả về dữ liệu nào và thao tác insert đang ghi lại toàn bộ lịch sử của bạn mỗi đêm.
Kiểm tra df.empty là dòng quan trọng nhất trong file. Ticker.history() không báo lỗi khi mã không đúng hoặc đã bị hủy niêm yết. Nó in cảnh báo rằng mã có thể đã bị hủy niêm yết và không tìm thấy dữ liệu giá (câu chữ chính xác thay đổi giữa các phiên bản library), rồi trả về một DataFrame rỗng. Job không có bước kiểm tra này sẽ không ghi dữ liệu, thoát với mã 0, và systemd vẫn hiển thị lần chạy thành công trong khi bảng âm thầm ngừng tăng. Vài tuần sau, bạn mới phát hiện ra khi một màn hình trả về cùng một tập bản ghi mỗi ngày.
Việc xử lý timestamp cũng có lý do. Index do feed trả về có thể chứa timezone của sàn. Xóa offset không tương đương với chuyển đổi sang UTC. Một phiên giao dịch tại Tokyo có timestamp là nửa đêm theo giờ địa phương sẽ được chuyển thành ngày trước đó theo UTC. Vì vậy, chuyển đổi sang UTC sẽ âm thầm đẩy mọi bar của Nhật lùi lại một ngày và làm hỏng primary key. tz_localize(None) giữ nguyên ngày giao dịch của sàn, đúng với ý nghĩa của một daily bar.
Bộ định thời chạy khi thị trường đóng cửa
Thị trường Mỹ đóng cửa lúc 16:00 theo giờ New York. Các giao dịch cuối cần thêm vài phút để hoàn tất, nên job chạy lúc 16:20. Hãy ghi thời gian theo giờ New York, không phải UTC. New York là UTC trừ 5 vào mùa đông và UTC trừ 4 vào mùa hè. Vì vậy, timer dùng một giờ UTC cố định sẽ lệch một giờ hai lần mỗi năm và bắt đầu chạy trước khi thị trường đóng cửa. systemd 235 trở lên cho phép chỉ định trực tiếp múi giờ trong OnCalendar. Ubuntu 24.04 đi kèm systemd 255. Xác nhận phiên bản đang dùng bằng 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 là loại service duy nhất chấp nhận nhiều hơn một ExecStart. Nó chạy chúng theo thứ tự và dừng nếu một lệnh thoát với mã khác 0. Đây chính xác là hành vi cần có: refresh thất bại thì không được tiếp tục chạy screen trên dữ liệu cũ. Persistent=true rất quan trọng trên VPS thường reboot để cập nhật kernel. Nếu reboot lúc 16:15 mà không có tùy chọn này, lần chạy sẽ bị bỏ qua. Khi có tùy chọn này, job sẽ chạy ngay sau khi máy hoạt động trở lại. Agent cần tiếp tục chạy thay vì chạy rồi thoát cần kiểu unit ngược lại: một tiến trình chạy lâu dài với các rule Restart= và không dùng timer. Phần chạy dsh không có giao diện trên VPS với systemd hướng dẫn cách cấu hình kiểu này.
sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timerlist-timers phải hiển thị một cột NEXT chứa lần chạy tiếp theo vào ngày trong tuần, được chuyển sang giờ địa phương của chính server. Danh sách trống nghĩa là timer chưa được enable hoặc unit thiếu section [Install], nên enable không có gì để liên kết vào timers.target.
Màn hình: SQL trước, model sau
SQL chính xác và không tốn chi phí cho mỗi lần chạy. Model thì không. Vì vậy, query thu hẹp phạm vi dữ liệu, và chỉ những bản ghi còn lại mới được gửi đến model. Lưu lại nội dung này dưới dạng /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;Bộ lọc n > 50 không phải để trang trí. ROWS BETWEEN 49 PRECEDING AND CURRENT ROW tính trung bình trên bất kỳ số dòng nào đang có, nên dòng thứ ba của một ticker sẽ trả về trung bình của 3 ngày nhưng vẫn gọi kết quả đó là ma50. Nếu so sánh kết quả này với ma20, bạn sẽ tạo ra một crossover ở đầu lịch sử của mọi ticker, dù crossover đó chưa từng xảy ra. Lọc theo số thứ tự dòng sẽ loại bỏ các dòng khi window chưa đủ dữ liệu.
Model thấy gì và không bao giờ thấy gì
# /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()Dòng thinking có vai trò quan trọng với model này. Claude Sonnet 5 mặc định có thinking, các thinking token được tính vào output và giới hạn max_tokens, rồi được trả về dưới dạng một block đứng trước phần văn bản. Nếu thiếu dòng đó, resp.content[0] có thể là thinking block và việc tra cứu .text sẽ thất bại. Claude Sonnet 5 hiện là model legacy. Model kế nhiệm là claude-sonnet-5-5, model này từ chối disabled, còn thiết lập cần dùng ở đó là thinking={'type': 'between_tools'} để tắt thinking trước khi trả lời.
Giới hạn số từ trong system prompt giới hạn phần chi phí cao của hóa đơn. Bạn phải kiểm tra thay vì tin ngay vào chỉ dẫn chỉ sử dụng các dòng đã cung cấp: xóa một cột khỏi block, chạy lại và đọc output. Nếu số liệu của cột đó vẫn xuất hiện, model đã tự điền phần còn thiếu và prompt của bạn chưa đủ chặt. Bài kiểm tra này chỉ mất 2 phút và là cách duy nhất để xác định một cách đáng tin cậy.
Model không bao giờ thấy API key, không bao giờ thấy đường dẫn database và không chạy query. Model nhận các dòng dữ liệu rồi trả về văn bản. Ranh giới này giúp kiểm tra output, vì mọi con số trong ghi chú cũng phải xuất hiện trong block bạn đã gửi và bạn có thể đối chiếu từng dòng. Ngay khi mở rộng input để bao gồm các trang lấy từ web, ranh giới này thay đổi, vì một headline hoặc bài đăng trên forum có thể chứa chỉ dẫn nhắm vào model thay vì dữ kiện dành cho bạn. Đó là bề mặt mà cung cấp cho agent một backend tìm kiếm SearXNG mở ra. Để tìm hiểu thêm về phần prompting, dùng Claude để phân tích tài chính trình bày sâu hơn về những nội dung model có thể đọc tốt.
Một lần chạy, từ đầu đến cuối
Lúc 16:20 theo giờ New York, timer khởi động service. refresh.py truy vấn feed cho từng ticker, bắt đầu từ ngày được lưu gần nhất, ghi thêm một hoặc hai bar cho mỗi ticker, rồi in một dòng cho từng ticker. screen.py mở cùng file đó, chạy truy vấn moving average và nhận về một số dòng. Các dòng này được chuyển thành một block văn bản dài vài trăm token. Một API call biến block này thành một ghi chú ngắn. Ghi chú được ghi vào journal, đồng thời một dòng được thêm vào runs cùng số lượng token và số lần hit.
journalctl -u research-refresh.service -n 50 --no-pagerMột log bình thường có một dòng cho mỗi ticker, sau đó là ghi chú và cuối cùng là research-refresh.service: Deactivated successfully. Nếu bạn đọc log đó từ một phiên Claude Code trên server, trong khi một phiên khác xử lý truy vấn trên màn hình, hai phiên có thể truyền văn bản cho nhau thay vì bạn phải sao chép các dòng giữa các terminal. Sau một tuần, hãy đọc lại chi phí thực tế từ database:
SELECT count(*) AS runs,
sum(input_tokens) AS in_tokens,
sum(output_tokens) AS out_tokens
FROM runs;Nhân các tổng đó với mức giá cho mỗi triệu token mà model của bạn công bố vào ngày bạn đọc chúng. Như vậy, bạn có con số thực tế thay vì một ước tính của người khác. Giá được công bố có thể thay đổi. Phép tính thì không.
Vì sao backtest bị overfit và cách quan sát quá trình đó
Viết lại bộ lọc dưới dạng hàm của hai độ dài cửa sổ, quét một grid gồm các cặp tham số rồi xếp hạng theo lợi nhuận. Cặp tốt nhất sẽ trông rất ấn tượng. Đó mới là vấn đề, không phải kết quả. Một grid gồm 200 cặp tương đương 200 lần thử nghiệm, và bạn đã giữ lại cặp may mắn nhất.
Bạn có thể quan sát quá trình này trong mười phút. Chia store thành hai nửa theo ngày. Chỉ quét grid trên nửa đầu và ghi lại cặp thắng. Quét cùng grid trên nửa thứ hai. Nếu hai cặp thắng cách xa nhau, các tham số đang khớp với nhiễu. Một cặp chỉ thắng trên nửa dữ liệu mà bạn dùng để tinh chỉnh không cho biết gì về ngày mai.
Survivorship còn nghiêm trọng hơn overfitting vì tuning không thể khắc phục nó. Danh sách ticker của bạn là các thành viên của index hiện tại, nên chỉ chứa những công ty còn tồn tại. Khi yêu cầu feed trả về một ticker đã bị delist vào năm 2019, feed trả về một frame rỗng. Điều đó có nghĩa là công ty đó chưa bao giờ được đưa vào store và cũng chưa bao giờ được đưa vào test. Mọi backtest bạn chạy đều đã loại bỏ các trường hợp thất bại từ trước.
Các chỉ số fundamentals được restate phá vỡ timeline. Con số doanh thu mà API trả về hôm nay cho một quý trong năm 2019 không phải lúc nào cũng là con số đã được công bố vào năm 2019. Một bộ lọc kết hợp fundamentals hiện tại với giá của năm 2019 đang sử dụng thông tin chưa tồn tại vào thời điểm đó. Giá thường an toàn trong trường hợp này. Fundamentals thường không.
Giá đã điều chỉnh thay đổi theo thời gian. Với auto_adjust=True, giá đóng cửa được điều chỉnh ngược về quá khứ để phản ánh cổ tức và chia tách cổ phiếu, nên cùng một query chạy vào tháng sau có thể trả về một lịch sử hơi khác. Lưu các row thực tế đã sử dụng là cách giúp kết quả có thể tái lập. Đây cũng là một lý do khác khiến local store tồn tại.
Backtest cũng bỏ qua commission và slippage, đồng thời giả định rằng order của bạn không làm giá thay đổi. Những vấn đề đó thuộc về execution, nằm ngoài phạm vi ở đây và được trình bày trong chạy trading bot trên VPS.
Các lỗi thường gặp và chuỗi thông báo bạn sẽ thấy
error: externally-managed-environment khi pip chạy. Bạn đang ở ngoài virtual environment. Hãy gọi /opt/research/venv/bin/pip bằng full path.
Could not set lock on file, kèm theo PID. Một process khác đang mở file DuckDB để ghi, thường là một interactive shell bạn quên đóng. Hãy đóng process đó. Mở connection thứ hai bằng read_only=True cũng không giúp được khi process đang ghi vẫn còn mở, vì DuckDB cho phép nhiều process đọc hoặc một process ghi, không cho phép cả hai cùng lúc.
Main process exited, code=killed, status=9/KILL trong systemctl status. Kernel đã kill job vì thiếu memory. Xác nhận bằng journalctl -k | grep -i oom, sau đó giảm memory_limit trong store.py.
Một lần chạy báo thành công nhưng không ghi gì. systemctl status đọc active (exited) và bảng không tăng thêm dữ liệu. Feed trả về các frame rỗng. Lỗi này khó phát hiện nhất, vì vậy hãy để job thoát với mã khác 0 khi tất cả ticker đều trả về rỗng.
Timer chạy vào ngày nghỉ của thị trường. systemd không biết lịch của sàn giao dịch, nên Mon-Fri vẫn bao gồm cả ngày nghỉ. Job vẫn chạy, feed không có dữ liệu mới, và job nên coi đây là trạng thái bình thường thay vì lỗi.
429 từ API. Bạn đã vượt quá rate limit. Anthropic SDK tự retry với backoff, còn Anthropic(max_retries=5) tăng số lần thử. Nếu lỗi vẫn xảy ra hằng ngày, job đang gửi quá nhiều yêu cầu trong một đợt.
Đây không phải là gì
Đây là một research assistant. Model tóm tắt một hồ sơ sẽ tạo ra cách diễn giải hồ sơ đó, và có thể khẳng định sai về một con số được in trong văn bản. Vì vậy, mọi số liệu trong ghi chú phải truy nguyên được về một dòng dữ liệu bạn đã gửi. Hãy xem output là danh sách rút gọn những nội dung bạn cần tự đọc. Nội dung này không phải là tư vấn tài chính và không có phần nào là tín hiệu giao dịch.
Backtest hữu ích để loại bỏ ý tưởng và không đáng tin khi dùng để xác nhận chúng. Một strategy thất bại trên dữ liệu của chính bạn thì thực sự đã không còn giá trị. Một strategy vượt qua backtest chỉ mới sống sót trên dữ liệu của bạn. Đây là một kết luận hạn chế hơn nhiều so với cảm giác lúc 1 giờ sáng.
Execution được cố ý loại khỏi phạm vi. Order và broker credential có mức rủi ro khác với một research box chỉ đọc. Việc trộn hai loại này khiến trading key nằm trên cùng một máy với LLM prompt. Nếu muốn xem mô hình này nằm ở đâu so với những thứ khác đáng chạy trên server riêng, hãy xem các AI agent self-host đáng chạy để có cái nhìn tổng quan hơn.
FAQ
Tôi có cần feed dữ liệu thị trường trả phí không?
Không cần cho prototype. Feed miễn phí không chính thức phù hợp để tìm hiểu cấu trúc hệ thống, và nó sẽ hỏng vì phụ thuộc vào một website không có nghĩa vụ phục vụ bạn. Feed này thường hỏng bằng cách trả về các frame rỗng thay vì phát sinh exception, nên job phải kiểm tra số lượng row. Khi dữ liệu bắt đầu được dùng để đưa ra quyết định, hãy chuyển sang feed trả phí có API được document và địa chỉ hỗ trợ. Store giúp việc thay thế này ít tốn công: chỉ cần đổi hàm fetch, còn schedule, schema và màn hình vẫn giữ nguyên.
Tôi nên lưu giá trong SQLite hay DuckDB?
DuckDB là cơ sở dữ liệu dạng cột và được thiết kế để quét nhiều row nhằm tính aggregate. Đây chính xác là cách tính moving average trên dữ liệu bar trong 10 năm. SQLite tổ chức dữ liệu theo row và phù hợp hơn với nhiều thao tác đọc, ghi nhỏ từ nhiều process cùng lúc. Với một job chạy theo schedule, append vài trăm row rồi quét hàng triệu row, DuckDB phù hợp hơn. Nếu nhiều process phải ghi cùng lúc, SQLite ở chế độ WAL cho phép reader tiếp tục hoạt động trong khi một writer commit. busy timeout khiến các writer khác chờ thay vì fail ngay.
Chi phí các cuộc gọi LLM mỗi tháng là bao nhiêu?
Ghi usage.input_tokens và usage.output_tokens từ mọi response vào một table, sau đó nhân tổng hằng tuần với giá cho mỗi triệu token mà model của bạn công bố tại thời điểm kiểm tra. Đây là con số duy nhất vẫn đáng tin vào quý sau. Một lần chạy mỗi ngày trên một screen ngắn chỉ tạo ra ít cuộc gọi. Độ dài của note là phần bạn kiểm soát được: giới hạn summary ở 200 từ tiết kiệm nhiều hơn việc gửi ít row hơn, vì token output có giá cao hơn token input trên mọi model Claude.
Vì sao job của tôi chạy thành công nhưng không ghi thêm row nào?
Có 2 nguyên nhân thường gặp. Thị trường đóng cửa vì schedule Mon-Fri của systemd có tính cả ngày nghỉ của sàn giao dịch. Hoặc feed trả về frame rỗng cho mọi ticker. Các client library thường báo trường hợp này bằng một warning được in ra thay vì exception, nên process vẫn exit với mã 0 và systemd vẫn hiển thị lần chạy thành công. Phân biệt 2 trường hợp bằng cách so sánh SELECT max(day) FROM prices với ngày giao dịch thực tế gần nhất, đồng thời cho job exit với mã khác 0 khi mọi ticker đều trả về rỗng.
Agent có thể quyết định nên mua gì không?
Không. Việc xây dựng agent theo hướng đó là nguyên nhân khiến các project này thường thất bại. Model không có quyền truy cập thị trường, không biết vị thế hoặc tình hình thuế của bạn, và không thể đối chiếu các con số của chính nó với bất kỳ nguồn nào. Điểm mạnh của nó là đọc một lượng lớn text và cho bạn biết một vài mục đáng được chú ý hôm nay. Mọi nội dung nó viết đều không phải là tư vấn tài chính. Quyết định và trách nhiệm liên quan vẫn thuộc về bạn.