feat(tools): read-only Taiwan stock snapshot tool (#848)
Adds a read-only agent tool over a published Taiwan-market SQLite
snapshot (stdlib + sqlite3 only, mode=ro&immutable=1, no network).
Maintainer repairs on top of the contribution:
- Bulk responses are now budgeted to 9,500 serialized characters so they
survive the agent loop's 10,000-char tool-result cut as valid JSON. At
the documented maximum (200 rows x 50 stocks) the response was 5.4 MB
and the prefix cut produced a JSON parse error. Rows are retained
newest-first, round-robin across stocks, so the newest bar of every
stock survives trimming; responses carry total_rows, returned_rows,
truncated and a narrowing hint. The budget covers history, universe,
latest and lookup — the size overflow was not limited to history.
- Indicators are no longer mis-dated. The tool picked the newest price
row and the newest feature row independently but reported a single
date, so 07-23 indicators were served under date=07-24. latest and
universe now return price_date, feature_date and a stale_features
flag.
- The Compose snapshot mount no longer defaults inside the checkout
(./data/tw-stock was also un-gitignored). The default host path is
now ${HOME}/.vibe-trading/tw-stock, overridable via
VIBE_TW_STOCK_DATA_DIR; no market data can land in the working tree.
- The snapshot schema (4 tables, 37 columns) is validated on open, so an
unrelated or stale SQLite file reports an actionable error instead of
registering successfully and failing mid-query.
- Fixed a connection leak: sqlite3's connection context manager commits
but never closes, leaking a handle per call.
The contributor's unrelated requirements-lock.txt rewrite (21 stale pin
changes, including FastAPI and OpenAI downgrades) was dropped; the tool
needs no dependency change.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,830 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent.loop import TOOL_RESULT_LIMIT
|
||||
from src.tools.taiwan_stock_data_tool import (
|
||||
MAX_QUERY_STOCKS,
|
||||
MAX_RESULT_ROWS,
|
||||
RESPONSE_CHAR_BUDGET,
|
||||
TaiwanStockDataTool,
|
||||
)
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE stock_master (
|
||||
stock_id TEXT PRIMARY KEY,
|
||||
stock_name TEXT NOT NULL,
|
||||
market TEXT,
|
||||
industry TEXT,
|
||||
enable INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE daily_price (
|
||||
date TEXT NOT NULL,
|
||||
stock_id TEXT NOT NULL,
|
||||
open REAL,
|
||||
max REAL,
|
||||
min REAL,
|
||||
close REAL,
|
||||
Trading_Volume REAL,
|
||||
Trading_money REAL,
|
||||
Trading_turnover REAL,
|
||||
spread REAL,
|
||||
UNIQUE(stock_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE stock_feature (
|
||||
date TEXT NOT NULL,
|
||||
stock_id TEXT NOT NULL,
|
||||
close REAL,
|
||||
ma5 REAL,
|
||||
ma20 REAL,
|
||||
ma60 REAL,
|
||||
ema12 REAL,
|
||||
ema26 REAL,
|
||||
macd REAL,
|
||||
macd_signal REAL,
|
||||
macd_hist REAL,
|
||||
rsi14 REAL,
|
||||
UNIQUE(stock_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE analysis_universe (
|
||||
stock_id TEXT PRIMARY KEY,
|
||||
stock_name TEXT NOT NULL,
|
||||
market TEXT,
|
||||
industry TEXT,
|
||||
active INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
price_rows INTEGER NOT NULL,
|
||||
last_price_date TEXT,
|
||||
last_feature_date TEXT,
|
||||
trading_day_lag INTEGER,
|
||||
latest_close REAL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
NEWEST_DATE = "2026-07-24"
|
||||
|
||||
|
||||
def _create_test_database(
|
||||
tmp_path: Path,
|
||||
) -> Path:
|
||||
db_path = tmp_path / "tw_stock_test.db"
|
||||
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.executescript(SCHEMA_SQL)
|
||||
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO stock_master (
|
||||
stock_id,
|
||||
stock_name,
|
||||
market,
|
||||
industry,
|
||||
enable
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
"2330",
|
||||
"台積電",
|
||||
"twse",
|
||||
"半導體業",
|
||||
1,
|
||||
),
|
||||
(
|
||||
"0054",
|
||||
"元大台商50",
|
||||
"twse",
|
||||
"ETF",
|
||||
1,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO daily_price
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
"2026-07-23",
|
||||
"2330",
|
||||
99.0,
|
||||
102.0,
|
||||
98.0,
|
||||
100.0,
|
||||
1000.0,
|
||||
100000.0,
|
||||
100.0,
|
||||
1.0,
|
||||
),
|
||||
(
|
||||
"2026-07-24",
|
||||
"2330",
|
||||
100.0,
|
||||
103.0,
|
||||
99.0,
|
||||
101.0,
|
||||
1200.0,
|
||||
121200.0,
|
||||
120.0,
|
||||
1.0,
|
||||
),
|
||||
(
|
||||
"2026-07-08",
|
||||
"0054",
|
||||
23.4,
|
||||
23.5,
|
||||
23.4,
|
||||
23.5,
|
||||
11000.0,
|
||||
258400.0,
|
||||
2.0,
|
||||
0.1,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO stock_feature
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
"2026-07-23",
|
||||
"2330",
|
||||
100.0,
|
||||
98.0,
|
||||
95.0,
|
||||
90.0,
|
||||
97.0,
|
||||
94.0,
|
||||
3.0,
|
||||
2.5,
|
||||
0.5,
|
||||
60.0,
|
||||
),
|
||||
(
|
||||
"2026-07-24",
|
||||
"2330",
|
||||
101.0,
|
||||
99.0,
|
||||
96.0,
|
||||
91.0,
|
||||
98.0,
|
||||
95.0,
|
||||
3.0,
|
||||
2.6,
|
||||
0.4,
|
||||
62.0,
|
||||
),
|
||||
(
|
||||
"2026-07-08",
|
||||
"0054",
|
||||
23.5,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
23.5,
|
||||
23.5,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO analysis_universe
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
"2330",
|
||||
"台積電",
|
||||
"twse",
|
||||
"半導體業",
|
||||
1,
|
||||
"active",
|
||||
618,
|
||||
"2026-07-24",
|
||||
"2026-07-24",
|
||||
0,
|
||||
101.0,
|
||||
"2026-07-25T00:00:00+00:00",
|
||||
),
|
||||
(
|
||||
"0054",
|
||||
"元大台商50",
|
||||
"twse",
|
||||
"ETF",
|
||||
0,
|
||||
"stale_price",
|
||||
5,
|
||||
"2026-07-08",
|
||||
"2026-07-08",
|
||||
11,
|
||||
23.5,
|
||||
"2026-07-25T00:00:00+00:00",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
db_path.chmod(0o444)
|
||||
return db_path
|
||||
|
||||
|
||||
def _create_bulk_database(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
stocks: int,
|
||||
rows: int,
|
||||
) -> Path:
|
||||
"""Build a snapshot wide enough to overflow the response budget."""
|
||||
db_path = tmp_path / "tw_stock_bulk.db"
|
||||
newest = date.fromisoformat(NEWEST_DATE)
|
||||
|
||||
masters = []
|
||||
universe = []
|
||||
prices = []
|
||||
features = []
|
||||
|
||||
for offset in range(stocks):
|
||||
stock_id = f"{2330 + offset:04d}"
|
||||
|
||||
masters.append(
|
||||
(
|
||||
stock_id,
|
||||
"台積電測試",
|
||||
"twse",
|
||||
"半導體業",
|
||||
1,
|
||||
)
|
||||
)
|
||||
universe.append(
|
||||
(
|
||||
stock_id,
|
||||
"台積電測試",
|
||||
"twse",
|
||||
"半導體業",
|
||||
1,
|
||||
"active",
|
||||
rows,
|
||||
NEWEST_DATE,
|
||||
NEWEST_DATE,
|
||||
0,
|
||||
101.25,
|
||||
"2026-07-25T00:00:00+00:00",
|
||||
)
|
||||
)
|
||||
|
||||
for step in range(rows):
|
||||
bar_date = (
|
||||
newest - timedelta(days=step)
|
||||
).isoformat()
|
||||
|
||||
prices.append(
|
||||
(
|
||||
bar_date,
|
||||
stock_id,
|
||||
100.5,
|
||||
103.25,
|
||||
99.75,
|
||||
101.25,
|
||||
1234567.0,
|
||||
123456789.0,
|
||||
12345.0,
|
||||
1.25,
|
||||
)
|
||||
)
|
||||
features.append(
|
||||
(
|
||||
bar_date,
|
||||
stock_id,
|
||||
101.25,
|
||||
99.123456,
|
||||
96.654321,
|
||||
91.987654,
|
||||
98.111111,
|
||||
95.222222,
|
||||
3.333333,
|
||||
2.444444,
|
||||
0.555555,
|
||||
62.666666,
|
||||
)
|
||||
)
|
||||
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.executescript(SCHEMA_SQL)
|
||||
connection.executemany(
|
||||
"INSERT INTO stock_master VALUES (?, ?, ?, ?, ?)",
|
||||
masters,
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO analysis_universe
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
universe,
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO daily_price
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
prices,
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO stock_feature
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
features,
|
||||
)
|
||||
|
||||
db_path.chmod(0o444)
|
||||
return db_path
|
||||
|
||||
|
||||
def _create_stale_feature_database(
|
||||
tmp_path: Path,
|
||||
) -> Path:
|
||||
"""Build a snapshot whose newest indicators lag the newest price bar."""
|
||||
db_path = tmp_path / "tw_stock_stale.db"
|
||||
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.executescript(SCHEMA_SQL)
|
||||
|
||||
connection.execute(
|
||||
"INSERT INTO stock_master VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
"2330",
|
||||
"台積電",
|
||||
"twse",
|
||||
"半導體業",
|
||||
1,
|
||||
),
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO daily_price
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
"2026-07-23",
|
||||
"2330",
|
||||
99.0,
|
||||
102.0,
|
||||
98.0,
|
||||
100.0,
|
||||
1000.0,
|
||||
100000.0,
|
||||
100.0,
|
||||
1.0,
|
||||
),
|
||||
(
|
||||
NEWEST_DATE,
|
||||
"2330",
|
||||
100.0,
|
||||
103.0,
|
||||
99.0,
|
||||
101.0,
|
||||
1200.0,
|
||||
121200.0,
|
||||
120.0,
|
||||
1.0,
|
||||
),
|
||||
],
|
||||
)
|
||||
# Features stop one trading day before the price series.
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO stock_feature
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"2026-07-23",
|
||||
"2330",
|
||||
100.0,
|
||||
98.0,
|
||||
95.0,
|
||||
90.0,
|
||||
97.0,
|
||||
94.0,
|
||||
3.0,
|
||||
2.5,
|
||||
0.5,
|
||||
60.0,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO analysis_universe
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"2330",
|
||||
"台積電",
|
||||
"twse",
|
||||
"半導體業",
|
||||
1,
|
||||
"active",
|
||||
2,
|
||||
NEWEST_DATE,
|
||||
"2026-07-23",
|
||||
0,
|
||||
101.0,
|
||||
"2026-07-25T00:00:00+00:00",
|
||||
),
|
||||
)
|
||||
|
||||
db_path.chmod(0o444)
|
||||
return db_path
|
||||
|
||||
|
||||
def test_check_available_uses_configured_path(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db_path = _create_test_database(tmp_path)
|
||||
|
||||
monkeypatch.setenv(
|
||||
"VIBE_TW_STOCK_DB",
|
||||
str(db_path),
|
||||
)
|
||||
|
||||
assert TaiwanStockDataTool.check_available()
|
||||
|
||||
|
||||
def test_status_returns_snapshot_summary(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = _create_test_database(tmp_path)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
payload = json.loads(
|
||||
tool.execute(action="status")
|
||||
)
|
||||
|
||||
assert payload["status"] == "success"
|
||||
assert (
|
||||
payload["data"]["latest_market_date"]
|
||||
== "2026-07-24"
|
||||
)
|
||||
assert (
|
||||
payload["data"]["active_analysis_stocks"]
|
||||
== 1
|
||||
)
|
||||
assert payload["data"]["integrity"] == "ok"
|
||||
|
||||
|
||||
def test_latest_returns_price_and_features(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = _create_test_database(tmp_path)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
payload = json.loads(
|
||||
tool.execute(
|
||||
action="latest",
|
||||
stock_ids=["2330"],
|
||||
)
|
||||
)
|
||||
|
||||
record = payload["data"]["records"][0]
|
||||
|
||||
assert record["stock_id"] == "2330"
|
||||
assert record["price_date"] == NEWEST_DATE
|
||||
assert record["feature_date"] == NEWEST_DATE
|
||||
assert record["stale_features"] is False
|
||||
assert record["close"] == 101.0
|
||||
assert record["ma60"] == 91.0
|
||||
assert record["rsi14"] == 62.0
|
||||
assert record["active"] == 1
|
||||
|
||||
|
||||
def test_history_limit_is_applied_per_stock(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = _create_test_database(tmp_path)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
payload = json.loads(
|
||||
tool.execute(
|
||||
action="history",
|
||||
stock_ids=["2330"],
|
||||
limit=1,
|
||||
)
|
||||
)
|
||||
|
||||
records = payload["data"]["records"]
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0]["date"] == "2026-07-24"
|
||||
|
||||
|
||||
def test_universe_defaults_to_active_only(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = _create_test_database(tmp_path)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
payload = json.loads(
|
||||
tool.execute(
|
||||
action="universe",
|
||||
limit=20,
|
||||
)
|
||||
)
|
||||
|
||||
records = payload["data"]["records"]
|
||||
|
||||
assert [row["stock_id"] for row in records] == [
|
||||
"2330"
|
||||
]
|
||||
|
||||
|
||||
def test_lookup_reports_unknown_stock_ids(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = _create_test_database(tmp_path)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
payload = json.loads(
|
||||
tool.execute(
|
||||
action="lookup",
|
||||
stock_ids=["2330", "9999"],
|
||||
)
|
||||
)
|
||||
|
||||
assert payload["data"]["not_found"] == ["9999"]
|
||||
|
||||
|
||||
def test_invalid_stock_id_is_rejected(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = _create_test_database(tmp_path)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Invalid Taiwan stock ID",
|
||||
):
|
||||
tool.execute(
|
||||
action="latest",
|
||||
stock_ids=["TSMC"],
|
||||
)
|
||||
|
||||
|
||||
def test_small_history_response_reports_no_truncation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = _create_test_database(tmp_path)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
data = json.loads(
|
||||
tool.execute(
|
||||
action="history",
|
||||
stock_ids=["2330"],
|
||||
)
|
||||
)["data"]
|
||||
|
||||
assert data["total_rows"] == 2
|
||||
assert data["returned_rows"] == 2
|
||||
assert data["truncated"] is False
|
||||
assert "hint" not in data
|
||||
|
||||
|
||||
def test_default_history_response_stays_parseable_after_agent_truncation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The default request must survive the agent loop's hard result cut."""
|
||||
db_path = _create_bulk_database(
|
||||
tmp_path,
|
||||
stocks=1,
|
||||
rows=MAX_RESULT_ROWS,
|
||||
)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
raw = tool.execute(
|
||||
action="history",
|
||||
stock_ids=["2330"],
|
||||
)
|
||||
|
||||
assert len(raw) <= RESPONSE_CHAR_BUDGET
|
||||
assert len(raw) < TOOL_RESULT_LIMIT
|
||||
|
||||
# The agent sees exactly raw[:TOOL_RESULT_LIMIT]; both must parse.
|
||||
payload = json.loads(raw)
|
||||
assert json.loads(raw[:TOOL_RESULT_LIMIT]) == payload
|
||||
|
||||
data = payload["data"]
|
||||
|
||||
assert data["truncated"] is True
|
||||
assert data["total_rows"] == 60
|
||||
assert 0 < data["returned_rows"] < data["total_rows"]
|
||||
assert len(data["records"]) == data["returned_rows"]
|
||||
assert data["hint"]
|
||||
|
||||
# Oldest rows go first, so the newest bar must still be there.
|
||||
assert NEWEST_DATE in {
|
||||
record["date"] for record in data["records"]
|
||||
}
|
||||
|
||||
|
||||
def test_max_boundary_history_response_stays_parseable_after_agent_truncation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""200 rows x 50 stocks is the widest request the schema allows."""
|
||||
db_path = _create_bulk_database(
|
||||
tmp_path,
|
||||
stocks=MAX_QUERY_STOCKS,
|
||||
rows=MAX_RESULT_ROWS,
|
||||
)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
raw = tool.execute(
|
||||
action="history",
|
||||
stock_ids=[
|
||||
f"{2330 + offset:04d}"
|
||||
for offset in range(MAX_QUERY_STOCKS)
|
||||
],
|
||||
limit=MAX_RESULT_ROWS,
|
||||
)
|
||||
|
||||
assert len(raw) <= RESPONSE_CHAR_BUDGET
|
||||
assert len(raw) < TOOL_RESULT_LIMIT
|
||||
|
||||
payload = json.loads(raw)
|
||||
assert json.loads(raw[:TOOL_RESULT_LIMIT]) == payload
|
||||
|
||||
data = payload["data"]
|
||||
|
||||
assert data["truncated"] is True
|
||||
assert (
|
||||
data["total_rows"]
|
||||
== MAX_QUERY_STOCKS * MAX_RESULT_ROWS
|
||||
)
|
||||
assert 0 < data["returned_rows"] < data["total_rows"]
|
||||
assert len(data["records"]) == data["returned_rows"]
|
||||
assert data["hint"]
|
||||
|
||||
assert NEWEST_DATE in {
|
||||
record["date"] for record in data["records"]
|
||||
}
|
||||
|
||||
|
||||
def test_universe_response_stays_within_budget(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = _create_bulk_database(
|
||||
tmp_path,
|
||||
stocks=MAX_QUERY_STOCKS,
|
||||
rows=2,
|
||||
)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
raw = tool.execute(
|
||||
action="universe",
|
||||
limit=MAX_RESULT_ROWS,
|
||||
)
|
||||
|
||||
assert len(raw) <= RESPONSE_CHAR_BUDGET
|
||||
|
||||
data = json.loads(raw)["data"]
|
||||
|
||||
assert data["truncated"] is True
|
||||
assert data["total_rows"] == MAX_QUERY_STOCKS
|
||||
assert NEWEST_DATE in {
|
||||
record["price_date"]
|
||||
for record in data["records"]
|
||||
}
|
||||
|
||||
|
||||
def test_latest_separates_price_and_feature_dates(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Indicators from an older date must not be dated as today's bar."""
|
||||
db_path = _create_stale_feature_database(tmp_path)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
record = json.loads(
|
||||
tool.execute(
|
||||
action="latest",
|
||||
stock_ids=["2330"],
|
||||
)
|
||||
)["data"]["records"][0]
|
||||
|
||||
assert record["price_date"] == NEWEST_DATE
|
||||
assert record["feature_date"] == "2026-07-23"
|
||||
assert record["stale_features"] is True
|
||||
|
||||
# The returned indicators are the 2026-07-23 values.
|
||||
assert record["close"] == 101.0
|
||||
assert record["rsi14"] == 60.0
|
||||
assert record["ma60"] == 90.0
|
||||
|
||||
|
||||
def test_universe_separates_price_and_feature_dates(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = _create_stale_feature_database(tmp_path)
|
||||
tool = TaiwanStockDataTool(db_path)
|
||||
|
||||
record = json.loads(
|
||||
tool.execute(
|
||||
action="universe",
|
||||
limit=10,
|
||||
)
|
||||
)["data"]["records"][0]
|
||||
|
||||
assert record["price_date"] == NEWEST_DATE
|
||||
assert record["feature_date"] == "2026-07-23"
|
||||
assert record["stale_features"] is True
|
||||
assert record["rsi14"] == 60.0
|
||||
|
||||
|
||||
def test_wrong_schema_database_is_refused(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A readable SQLite file with the wrong tables must not register."""
|
||||
db_path = tmp_path / "unrelated.db"
|
||||
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)"
|
||||
)
|
||||
|
||||
monkeypatch.setenv(
|
||||
"VIBE_TW_STOCK_DB",
|
||||
str(db_path),
|
||||
)
|
||||
|
||||
assert not TaiwanStockDataTool.check_available()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="missing table 'stock_master'",
|
||||
):
|
||||
TaiwanStockDataTool(db_path).execute(
|
||||
action="status"
|
||||
)
|
||||
|
||||
|
||||
def test_missing_column_is_named_in_the_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = tmp_path / "partial.db"
|
||||
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.executescript(
|
||||
SCHEMA_SQL.replace(
|
||||
" rsi14 REAL,\n",
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="'stock_feature' is missing columns 'rsi14'",
|
||||
):
|
||||
TaiwanStockDataTool(db_path).execute(
|
||||
action="status"
|
||||
)
|
||||
|
||||
|
||||
def test_non_sqlite_file_is_refused(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db_path = tmp_path / "not_a_database.db"
|
||||
db_path.write_text("plain text, not a snapshot")
|
||||
|
||||
monkeypatch.setenv(
|
||||
"VIBE_TW_STOCK_DB",
|
||||
str(db_path),
|
||||
)
|
||||
|
||||
assert not TaiwanStockDataTool.check_available()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="not a readable SQLite database",
|
||||
):
|
||||
TaiwanStockDataTool(db_path).execute(
|
||||
action="status"
|
||||
)
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
- agent/.env
|
||||
environment:
|
||||
- VIBE_TRADING_TRUST_DOCKER_LOOPBACK=1
|
||||
- VIBE_TW_STOCK_DB=/data/tw-stock/latest.db
|
||||
# Ollama runs on the host; from inside the container "localhost" is the
|
||||
# container itself, so default to reaching the host via host.docker.internal.
|
||||
# Override by exporting OLLAMA_BASE_URL (or setting it in a top-level .env)
|
||||
@@ -30,6 +31,12 @@ services:
|
||||
# src/api/helpers.py writes to /app/agent/.env) across container recreation.
|
||||
# env_file above still seeds the initial boot env from the same file.
|
||||
- ./agent/.env:/app/agent/.env
|
||||
# Published Taiwan stock snapshots, mounted read-only. The default host
|
||||
# path stays OUTSIDE the checkout: no market data may ever land in the
|
||||
# working tree. Compose does not expand "~" in volume paths, so this uses
|
||||
# ${HOME}. Override VIBE_TW_STOCK_DATA_DIR in the root .env with another
|
||||
# absolute path outside the repo.
|
||||
- ${VIBE_TW_STOCK_DATA_DIR:-${HOME}/.vibe-trading/tw-stock}:/data/tw-stock:ro
|
||||
# --- Runtime hardening (VT-007) ---
|
||||
cap_drop:
|
||||
- ALL
|
||||
|
||||
Reference in New Issue
Block a user