Compare commits

...

20 Commits

Author SHA1 Message Date
83495bcd23 chore(deploy): keep start scripts and systemd units in deploy/
The three services become quant-trading-monitor, quant-trading-api and
quant-trading-vite, run from /home/gahow/projects/quant-trading. Their
units were previously untracked files in ~/.config/systemd/user.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:42:40 +08:00
96a845ebcd refactor: wire web to the in-repo engine and shared data dir
- web/src/server/paths.ts resolves the repo root from its own location,
  loads the single repo-root .env, and defines data/web/ (watchlist,
  stock cache, backtest cache) and data/precomputed/.
- The API spawns bridge.py at the repo root instead of ../quant.
- precompute_backtest.py writes to data/precomputed/ instead of
  ../stock-agent/data/precomputed.
- Rename the project to quant-trading (web package quant-trading-web).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:42:35 +08:00
0eb5b95efa Merge stock-agent history into web/
stock-agent (the web UI and API that shell out to bridge.py) now lives in
this repository under web/, with its full commit history rewritten into
that prefix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:39:31 +08:00
f6b46d4d68 chore: track precompute and ops scripts; move monitor proxy into .env
The monitor start script used to embed the proxy URL with credentials,
which kept scripts/ out of git. It now reads QUANT_MARKET_PROXY from the
untracked repo-root .env.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:35:59 +08:00
6dc623b4a1 fix(backtest): give mixed stock+ETF strategies their ETFs; derive markets from trader
trend_rider_v6 backtests failed ('SPY' not in data) because only the stock
universe was passed. Strategy markets in 'list' now follow the trader's
eligibility rule, so v6 is no longer offered for CN.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:16:48 +08:00
69636c3fae perf(monitor): refresh each price panel once per phase
Every strategy used to re-download its whole universe each phase (~75
downloads of 500+ tickers for the US book), which tripped Yahoo's rate
limit. The monitor now refreshes each panel once for the union of tickers
and runs strategies offline, like replay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:09:02 +08:00
10a74ea4f7 fix(trader): record trade and snapshot prices at full precision
Rounding to cents made trade logs irreproducible for low-priced lines
(HK ETFs in USD, A-shares): replaying a log drifted up to $200 from the
recorded cash.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:05:15 +08:00
592de02e27 perf(replay): refresh panels once and compute signals in one pass
Replay refreshes each price panel once for the union of tickers and then
replays offline; target weights for all sessions come from one causal
signal pass instead of re-running the strategy per day (verified identical
daily equity for v7_vt36, recovery_mom_top10 and fc monthly).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:56:32 +08:00
0d3642435b fix(data): judge late newest rows as sparse; require SPY print only in the ETF panel
Tickers count as alive for 10 rows after their last bar, so a newest row
printed by a few early tickers is dropped until the rest arrive. The
benchmark-print session rule now applies only to the mixed US/HK ETF panel:
Yahoo's 510300.SS lags CN stocks by days and would stall the CN book.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:51:02 +08:00
eefd879a93 fix(strategy-ui): correct rankings, currency, injection view and staleness
- MaxDD ranks shallower drawdowns first (values are negative); ruined
  results are badged and never ranked as best.
- Metrics render '—' for null/non-finite values instead of crashing.
- Amounts use the market's currency (CNY for CN) instead of hard-coded USD.
- Injection scenarios show invested / final value / profit / return on
  invested from capitalFlows and label return metrics as time-weighted.
- Precomputed results show their data end date and warn when older than 7
  days; live strategies warn when not updated for 4 days, show start date,
  price as-of, stale holdings, and same-window benchmark with excess return.
- Unknown precomputed scenario returns 400 instead of silently serving the
  first scenario; the client uses the scenarioId it receives.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:22:07 +08:00
1697bc1641 fix(report): correct periodic returns, injection metrics and live view
- Monthly/yearly returns chain from the previous period's close (the first
  trading day of every period was dropped; up to ~9pp/yr error).
- With cash injections, metrics/drawdowns/periodic returns are time-weighted
  on the no-injection curve; capitalFlows reports invested/profit separately.
- Benchmarks cover exactly the strategies' dates.
- CAGR uses calendar time, so gaps in live records do not inflate it.
- Backtests skip the per-trade fee for rebalances below the live minimum
  order and keep a ruined portfolio at zero.
- live: exact strategy match, ineligible strategies hidden, currency,
  startDate, same-window benchmark, stale-price fallback, equity anchored at
  the initial capital.
- PIT membership falls back to the cached history when the Wikipedia change
  log cannot be parsed (the table is no longer on the page).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:16:33 +08:00
11f4dd95dd fix(trader): make live sessions complete, final-close based and executable
- Process every completed session since the last recorded one, so monitor
  downtime and late data are caught up; today's bar only counts after the
  exchange closes. The monitor's evening phase always goes through auto and
  catches up at startup.
- Tickers without a real closing print are valued at the last price but not
  traded; held tickers missing from the panel keep their last recorded price
  instead of being valued at zero.
- Use registry parameters (live momentum strategies ran top_n=len//10=49
  instead of the backtested 20).
- A-share orders use 100-share board lots; per-market default capital
  (US $10k, CN ¥70k); HK tickers are converted to USD (HKD=X, peg fallback)
  and exempt from the US per-share fee.
- ETF strategies only run in the US book; buy_and_hold is excluded from live
  because its ~$20 slices never reach the minimum order size.
- CN benchmark switches to 510300.SS (Yahoo's 000300.SS is truncated).
- New 'replay' command rebuilds live state files from each start date.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:16:14 +08:00
838749d8b2 fix(data): store raw price panels and refresh a trailing window on every update
The append-only updater only fetched when last_date+1 < local date, never
revisited appended rows and forward-filled before saving. First-seen values
(partial intraday bars, late or missing prints) were frozen permanently: vs
Yahoo, 22% (US), 58% (CN) and 27% (ETF) of recent days had >0.5% daily-return
errors, CN never received same-day data, and HK tickers created US-holiday rows.

Panels are now stored raw (NaN = no print) and forward-filled on read; every
update re-downloads the last 30 days for the requested tickers, rescales older
history when Yahoo's split/dividend adjustment moves, drops rows printed by a
minority of tickers, and writes atomically under a per-market file lock.
Tickers Yahoo has no data for are retried weekly instead of every run.
rebuild() repairs panels written by the old updater.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:16:10 +08:00
c2882ae4cf fix(live): remount strategy detail when switching focused strategy
Keying LiveStrategyDetail by strategy name resets its child state (trade-log
pagination), so switching strategies no longer keeps the previous page index.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:17:09 +08:00
f0739185d4 chore(deploy): bind vite to localhost behind nginx and enable env proxy for node fetch
Vite is now only an upstream for the nginx :5443 HTTPS entrypoint, and the
API server opts into NODE_USE_ENV_PROXY so Nasdaq/Yahoo requests go through
the host proxy. Ignore the .qoder/ IDE session directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:16:34 +08:00
5716267309 feat(ui): split client into Live and Strategy pages and wire new routes
Rebuilds the client into Live (watchlist with charts, fundamentals, news,
quant analysis) and Strategy (backtest runner / precomputed results) pages,
adds the corresponding /api/stocks/:symbol/chart|fundamentals|related|quant
and /api/strategies endpoints, and updates project tooling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:21:03 +08:00
f54041646f feat(strategy): add backtest strategy module
Introduces strategyTypes shared schema and a server-side strategyService
that lists strategies, runs backtests, and caches results.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:20:50 +08:00
a4a2cb2ccf feat(data): extend market data provider with charts, fundamentals, and quant analysis
Adds a stock data cache layer and indicator helpers (MA/RSI/MACD), expands
the MarketDataProvider interface with candles, fundamentals, related-data,
and quant analysis, and threads tags + source attribution through the
shared types. Updates affected agent tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:20:42 +08:00
30f6cd84a8 chore: add dev start scripts for server and vite
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:59:32 +08:00
c87f37974e feat: bootstrap personal stock agent 2026-05-11 21:10:55 +08:00
50 changed files with 14485 additions and 500 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Copy to .env (untracked) at the repository root.
# IPADS Responses API key for the web watchlist agent; blank uses demo data.
IPADS_API_KEY=
# Proxy URL for the live monitor's market-data fetches (deploy/start-monitor.sh).
QUANT_MARKET_PROXY=

15
.gitignore vendored
View File

@@ -44,3 +44,18 @@ docs/superpowers/
# OS
.DS_Store
Thumbs.db
# Price panel locks, repair backups and Yahoo availability markers
data/.*.lock
data/backup_*/
data/*_unavailable.json
# Local secrets, logs and agent scratch output
.env
logs/
memory/
.tmp_finance/
# Web UI state and precomputed backtests (web/src/server/paths.ts)
data/web/
data/precomputed/

View File

@@ -4,7 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Quantitative backtesting framework for comparing equity trading strategies across multiple markets. Supports US (S&P 500) and China A-shares (CSI 300). Uses Yahoo Finance data with persistent local CSV storage. Includes a daily trading simulator with automated cron support.
quant-trading: a quantitative backtesting framework and live forward-testing trader for equity strategies across US (S&P 500) and China A-shares (CSI 300), plus a web UI to browse backtests and live books. Uses Yahoo Finance data with persistent local CSV storage.
The repository has two halves that share one data directory and one `.env`:
- **Python engine** (repo root, `uv`): strategies, backtests, `trader.py`, research scripts.
- **Web UI and API** (`web/`, Node + React + Vite): the API spawns `uv run python bridge.py {list,backtest,live}` at the repo root and reads `data/precomputed/backtest_results.json`, written by `precompute_backtest.py`.
`bridge.py`'s JSON output and `web/src/shared/strategyTypes.ts` are one contract: change both in the same commit.
## Commands
@@ -30,11 +37,21 @@ uv run python trader.py simulate --market us --strategy recovery_mom_top10 --sta
uv run python trader.py log --market us --strategy sim_recovery_mom_top10 --start 2026-03-01 # View daily log
uv run python trader.py simulate --integer-shares --fixed-fee 2.0 ... # Integer shares mode
# Precompute the strategy page's backtests (writes data/precomputed/)
uv run python precompute_backtest.py
# Setup
uv sync # Install/sync dependencies
```
cp .env.example .env # Then fill IPADS_API_KEY and QUANT_MARKET_PROXY
(cd web && npm ci)
No test suite or linter is configured.
# Tests
uv run python -m unittest discover tests
(cd web && npm test && npm run build)
# Web UI in development (API :8787, Vite :5173)
(cd web && npm run dev)
```
## Architecture
@@ -67,36 +84,44 @@ No test suite or linter is configured.
- Strategies output **weights**, not buy/sell signals. Weights are normalized so each row sums to 1 (or 0 during warm-up).
- Warm-up periods: each strategy zeros out its first N rows where indicators are undefined.
- `data/` contains persistent price CSVs, universe JSON caches, and trader state files. Not checked into git (only `data/.gitkeep` is tracked).
- `data/` contains persistent price CSVs, universe JSON caches, and trader state files, plus `data/precomputed/` (precomputed backtests) and `data/web/` (watchlist, per-stock cache, backtest result cache). Not checked into git (only `data/.gitkeep` and `data/sp500_history.json` are tracked).
- Python 3.12+, managed with `uv`.
- Do not show matplotlib figures when running backtests; use `--no-plot`.
## Server Deployment
### Option 1: tmux monitor (recommended)
Three `systemd --user` services, defined in `deploy/systemd/`, run from `/home/gahow/projects/quant-trading`:
Single process monitors both US and CN markets. Two phases per market daily — morning (open → signals) and evening (close → execute). All 9 strategies run for each market.
| Service | Script | Role |
|---|---|---|
| `quant-trading-monitor` | `deploy/start-monitor.sh` | `trader.py monitor`: morning/evening phases for US + CN, all live strategies |
| `quant-trading-api` | `deploy/start-web-api.sh` | Web API on `127.0.0.1:8787` |
| `quant-trading-vite` | `deploy/start-web-vite.sh` | Vite on `127.0.0.1:5174`, behind nginx on `:5443` |
```bash
tmux new -s quant
uv run python trader.py monitor # Both US + CN, all strategies
# Ctrl-B D to detach; tmux attach -t quant to reconnect
cp deploy/systemd/*.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now quant-trading-monitor quant-trading-api quant-trading-vite
journalctl --user -u quant-trading-monitor -f
```
# After a month, compare:
The monitor catches up on startup, so restarting it between phases is safe. If it starts mid-day (after open, before close), it falls back to `auto` mode for the evening phase.
Compare live books after a while:
```bash
uv run python trader.py compare # US strategies
uv run python trader.py compare --market cn # CN strategies
```
If monitor starts mid-day (after open, before close), it automatically falls back to `auto` mode for the evening phase.
### Option 2: Cron
### Alternative: cron
Run daily after market close. The `auto` command is idempotent — safe to re-run:
```bash
# US market: 5pm ET weekdays
0 17 * * 1-5 cd /path/to/quant && uv run python trader.py auto --market us --strategy recovery_mom_top10 >> data/cron.log 2>&1
0 17 * * 1-5 cd /home/gahow/projects/quant-trading && uv run python trader.py auto --market us --strategy recovery_mom_top10 >> data/cron.log 2>&1
# CN market: 4pm CST weekdays
0 16 * * 1-5 cd /path/to/quant && uv run python trader.py auto --market cn --strategy inverse_vol >> data/cron.log 2>&1
0 16 * * 1-5 cd /home/gahow/projects/quant-trading && uv run python trader.py auto --market cn --strategy inverse_vol >> data/cron.log 2>&1
```

324
bridge.py
View File

@@ -1,5 +1,5 @@
"""
Bridge script for stock-agent integration.
Bridge between the Python engine and the web API (web/src/server/strategyService.ts).
Outputs JSON to stdout; all logs go to stderr.
@@ -24,13 +24,24 @@ import universe_history as uh
from main import backtest
from research.permanent_yearly import equity_with_cashflows
from trader import (
strategy_extra_tickers,
strategy_supports_market,
ETF_STRATEGY_UNIVERSES,
LIVE_EXCLUDED_STRATEGIES,
MARKET_FEE_TIERED,
STRATEGY_REGISTRY,
strategy_universe,
convert_quote_currency,
price_request,
strategy_supports_market,
strategy_data_market,
load_state,
filter_tradable_tickers,
)
MARKET_CURRENCY = {"us": "USD", "cn": "CNY"}
# Rebalances below this size are not charged the per-trade fee in backtests,
# matching the live trader's minimum order size.
MIN_TRADE_VALUE = 50.0
from universe import UNIVERSES
@@ -567,27 +578,37 @@ def compute_rebalance_win_rate(equity: pd.Series, trade_events: list[dict]) -> f
def compute_periodic_returns(equity: pd.Series) -> tuple[dict, dict]:
"""Compute monthly and yearly returns from an equity curve."""
monthly = {}
yearly = {}
"""Monthly and yearly returns from an equity curve.
# Monthly returns
for (year, month), group in equity.groupby([equity.index.year, equity.index.month]):
if len(group) < 2:
continue
ret = group.iloc[-1] / group.iloc[0] - 1
monthly[f"{year}-{month:02d}"] = round(float(ret), 4)
# Yearly returns
for year, group in equity.groupby(equity.index.year):
if len(group) < 2:
continue
ret = group.iloc[-1] / group.iloc[0] - 1
yearly[str(year)] = round(float(ret), 4)
Each period is measured from the previous period's last value (the first
period from the curve's first value), so the first trading day's move of
every period is included.
"""
def by_period(keys) -> dict:
ends = equity.groupby(keys).last()
out = {}
prev = float(equity.iloc[0])
for key, end in ends.items():
if prev > 0:
out[key] = round(float(end) / prev - 1, 4)
prev = float(end)
return out
idx = equity.index
monthly = by_period([f"{d.year}-{d.month:02d}" for d in idx])
yearly = by_period([str(d.year) for d in idx])
return monthly, yearly
def benchmark_curve(prices: pd.Series, dates: pd.DatetimeIndex, capital: float) -> list[dict]:
"""Benchmark value on ``dates``, rebased to ``capital`` on the first date it has a price."""
series = prices.reindex(dates).ffill().dropna()
if series.empty:
return []
values = series / series.iloc[0] * capital
return [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)} for d, v in values.items()]
def build_injection_schedule(equity: pd.Series, schedule: dict) -> tuple[pd.Series, list[dict]]:
"""Build a contributions Series from injection schedule config.
@@ -679,7 +700,7 @@ def cmd_list() -> None:
"label": meta["label"],
"category": meta["category"],
"params": meta["params"],
"markets": meta["markets"],
"markets": [m for m in meta["markets"] if strategy_supports_market(name, m)],
})
json.dump(strategies, sys.stdout, indent=2)
@@ -735,10 +756,12 @@ def cmd_backtest(args: argparse.Namespace) -> None:
with _redirect_stdout_to_stderr():
pit_intervals = uh.load_sp500_history()
historical_tickers = uh.all_tickers_ever(pit_intervals)
all_tickers = sorted(set(tickers + historical_tickers + [benchmark_ticker]))
all_tickers = sorted(set(tickers + historical_tickers + [benchmark_ticker])
| {t for n in stock_strategies for t in strategy_extra_tickers(n)})
_log(f"PIT universe: {len(all_tickers)} tickers (current + historical members)")
else:
all_tickers = sorted(set(tickers + [benchmark_ticker]))
all_tickers = sorted(set(tickers + [benchmark_ticker])
| {t for n in stock_strategies for t in strategy_extra_tickers(n)})
_log(f"WARNING: {market.upper()} market lacks PIT membership history; "
"backtest uses current index constituents (survivorship bias possible)")
@@ -772,22 +795,25 @@ def cmd_backtest(args: argparse.Namespace) -> None:
for name in stock_strategies:
_log(f" Running: {name}")
strategy_fn = STRATEGY_REGISTRY[name]
# Derive top_n from strategy metadata (respects per-strategy defaults)
meta = STRATEGY_META.get(name, {})
top_n = int(meta.get("params", {}).get("top_n", {}).get("default", 10))
strategy = strategy_fn(top_n=top_n)
# Registry defaults are the single source of strategy parameters,
# shared with the live trader.
strategy = STRATEGY_REGISTRY[name]()
# Mixed stock+ETF strategies also read their fixed ETFs.
cols = tickers + [t for t in strategy_extra_tickers(name)
if t in data.columns and t not in tickers]
equity = backtest(
strategy, data[tickers],
strategy, data[cols],
initial_capital=capital,
transaction_cost=tx_cost,
fixed_fee=fixed_fee,
min_trade_value=MIN_TRADE_VALUE,
**MARKET_FEE_TIERED.get(market, {}),
)
# Get weight signals for trade extraction
weights = strategy.generate_signals(data[tickers])
weights = weights.reindex(data[tickers].index).fillna(0.0)
weights = strategy.generate_signals(data[cols])
weights = weights.reindex(data[cols].index).fillna(0.0)
trade_events = compute_rebalance_trades(weights)
result_entry = _build_strategy_result(
@@ -795,14 +821,8 @@ def cmd_backtest(args: argparse.Namespace) -> None:
)
results.append(result_entry)
# Benchmark
bench = data[benchmark_ticker].dropna()
if years:
cutoff = bench.index[-1] - pd.DateOffset(years=years)
bench = bench[bench.index >= cutoff]
bench_equity = (bench / bench.iloc[0]) * capital
bench_curve = [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
for d, v in bench_equity.items()]
# Benchmark over exactly the strategies' dates
bench_curve = benchmark_curve(data[benchmark_ticker], data.index, capital)
else:
benchmark_label = "SPY"
bench_curve = []
@@ -816,16 +836,16 @@ def cmd_backtest(args: argparse.Namespace) -> None:
all_etf = sorted(set(etf_tickers + ["SPY"]))
with _redirect_stdout_to_stderr():
etf_data = data_manager.update("etfs", all_etf, with_open=False)
etf_data = data_manager.update("etfs", price_request(market, all_etf), with_open=False)
if isinstance(etf_data, tuple):
etf_data = etf_data[0]
etf_data = convert_quote_currency(etf_data)
if years:
cutoff = etf_data.index[-1] - pd.DateOffset(years=years)
etf_data = etf_data[etf_data.index >= cutoff]
strategy_fn = STRATEGY_REGISTRY[name]
strategy = strategy_fn()
strategy = STRATEGY_REGISTRY[name]()
tradable = filter_tradable_tickers(etf_data, etf_tickers)
equity = backtest(
@@ -833,6 +853,8 @@ def cmd_backtest(args: argparse.Namespace) -> None:
initial_capital=capital,
transaction_cost=tx_cost,
fixed_fee=fixed_fee,
min_trade_value=MIN_TRADE_VALUE,
**MARKET_FEE_TIERED.get(market, {}),
)
weights = strategy.generate_signals(etf_data[tradable])
@@ -855,10 +877,7 @@ def cmd_backtest(args: argparse.Namespace) -> None:
cutoff = spy_data.index[-1] - pd.DateOffset(years=years)
spy_data = spy_data[spy_data.index >= cutoff]
if "SPY" in spy_data.columns:
bench = spy_data["SPY"].dropna()
bench_equity = (bench / bench.iloc[0]) * capital
bench_curve = [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
for d, v in bench_equity.items()]
bench_curve = benchmark_curve(spy_data["SPY"], spy_data.index, capital)
output = {
"meta": {
@@ -888,7 +907,13 @@ def _build_strategy_result(
injection_schedule: dict | None,
initial_capital: float,
) -> dict:
"""Build a strategy result dict with optional injection applied."""
"""Build a strategy result dict with optional injection applied.
Return and risk metrics, drawdowns and periodic returns are time-weighted
(computed on the curve without injections), so injected cash never counts
as performance. ``capitalFlows`` reports the money view: total invested,
final value and profit of the injected curve.
"""
injection_events = []
equity_with_inj = None
@@ -897,8 +922,10 @@ def _build_strategy_result(
contributions, injection_events = build_injection_schedule(equity, injection_schedule)
equity_with_inj = equity_with_cashflows(returns, contributions, initial_capital)
# Use injected equity for metrics/curves if available
# Curve shown to the user: the money curve when cash is injected.
final_equity = equity_with_inj if equity_with_inj is not None else equity
invested = initial_capital + sum(ev["amount"] for ev in injection_events)
final_value = float(final_equity.iloc[-1])
# Equity curve
curve = [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
@@ -910,17 +937,14 @@ def _build_strategy_result(
base_curve = [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
for d, v in equity.items()]
# Metrics
strat_metrics = metrics.raw_summary(final_equity)
# Time-weighted metrics on the curve without injections
strat_metrics = metrics.raw_summary(equity)
strat_metrics["rebalanceWinRate"] = compute_rebalance_win_rate(
final_equity, trade_events,
equity, trade_events,
)
# Drawdown periods
drawdowns = compute_drawdown_periods(final_equity)
# Monthly/yearly returns
monthly_ret, yearly_ret = compute_periodic_returns(final_equity)
drawdowns = compute_drawdown_periods(equity)
monthly_ret, yearly_ret = compute_periodic_returns(equity)
meta = STRATEGY_META.get(name, {"label": name, "category": "other"})
@@ -933,6 +957,13 @@ def _build_strategy_result(
"drawdowns": drawdowns,
"trades": trade_events,
"injections": injection_events,
"capitalFlows": {
"invested": round(invested, 2),
"finalValue": round(final_value, 2),
"profit": round(final_value - invested, 2),
"returnOnInvested": final_value / invested - 1 if invested else None,
},
"ruined": bool((equity <= 0).any()),
"monthlyReturns": monthly_ret,
"yearlyReturns": yearly_ret,
}
@@ -942,81 +973,81 @@ def _build_strategy_result(
# Subcommand: live
# ---------------------------------------------------------------------------
def _load_latest_prices(markets_needed: set[str]) -> dict[str, dict]:
"""Pull the latest close + as-of date for each cached market panel.
LIVE_BENCHMARK = {"us": ("SPY", "SPY"), "cn": ("510300.SS", "CSI 300 ETF 510300")}
Returns ``{market: {"asof": str|None, "prices": {ticker: float}}}``.
We read the on-disk CSVs directly so this stays fast even when called
against a freshly initialized live state with no network.
def _load_panels(markets_needed: set[str]) -> dict[str, pd.DataFrame | None]:
"""Load each cached close panel once (forward-filled, HK tickers in USD).
Reads the on-disk CSVs directly so the live view stays fast and works
without network access.
"""
import data_manager # local import to avoid heavyweight import at module load
out: dict[str, dict] = {}
panels: dict[str, pd.DataFrame | None] = {}
for mk in markets_needed:
try:
df = data_manager.load(mk)
except Exception as e: # noqa: BLE001 — never fatal
_log(f" load_latest_prices: cannot load {mk}: {e}")
out[mk] = {"asof": None, "prices": {}}
continue
if df is None or df.empty:
out[mk] = {"asof": None, "prices": {}}
continue
last_row = df.iloc[-1]
out[mk] = {
"asof": df.index[-1].strftime("%Y-%m-%d"),
"prices": {col: float(v) for col, v in last_row.items()
if v is not None and not (isinstance(v, float) and np.isnan(v))},
}
_log(f" load panel {mk}: {e}")
df = None
panels[mk] = convert_quote_currency(df) if df is not None and not df.empty else None
return panels
def _latest_prices(panel: pd.DataFrame | None) -> dict:
if panel is None:
return {"asof": None, "prices": {}}
last_row = panel.iloc[-1]
return {
"asof": panel.index[-1].strftime("%Y-%m-%d"),
"prices": {col: float(v) for col, v in last_row.items() if pd.notna(v)},
}
def _last_recorded_prices(state: dict) -> dict[str, float]:
"""Most recent price recorded in the daily log for each ticker."""
out: dict[str, float] = {}
for entry in reversed(state.get("daily_log", []) or []):
for ticker, pos in (entry.get("holdings") or {}).items():
if ticker not in out and pos.get("price"):
out[ticker] = float(pos["price"])
return out
def _strategy_data_market_safe(strategy_name: str, default: str) -> str:
"""Return the price-data market a strategy reads from.
Mirrors trader.strategy_data_market without importing trader (to keep
bridge import-cheap when the CLI is hit). Tactical-allocation strategies
trade ETFs, so they pull from the 'etfs' panel; stock pickers stay on
their nominal market.
"""
try:
from trader import ETF_STRATEGY_UNIVERSES # noqa: WPS433
except Exception:
return default
base = strategy_name.removeprefix("sim_")
return "etfs" if base in ETF_STRATEGY_UNIVERSES else default
def cmd_live(args: argparse.Namespace) -> None:
"""Read live trader state files and output normalized JSON.
Each strategy's record contains:
- Current cash, holdings, marked-to-market value of each holding (using
the latest cached close price), and total assets.
- Daily equity curve from the trader's persisted state.
- Current cash, holdings, marked-to-market value of each holding (latest
cached price, falling back to the last recorded price), and total assets.
- Daily equity curve starting from the initial capital, plus the market
benchmark rebased to the same capital over the same dates.
- Full trade log (one entry per ticker per fill) + day-aggregated view.
- Metrics, drawdowns, monthly/yearly returns.
"""
market = args.market
pattern = os.path.join("data", f"trader_{market}_*.json")
state_files = sorted(glob(pattern))
prefix = f"trader_{market}_"
state_files = sorted(glob(os.path.join("data", f"{prefix}*.json")))
wanted = args.strategy
if args.strategy:
state_files = [f for f in state_files if args.strategy in f]
_log(f"Found {len(state_files)} state files for market={market}")
# Pre-load price panels once. Tactical/ETF strategies read from 'etfs',
# everything else from the nominal market.
markets_needed = {market, "etfs"}
latest_prices = _load_latest_prices(markets_needed)
panels = _load_panels({market, "etfs"})
bench_ticker, bench_label = LIVE_BENCHMARK[market]
bench_panel = panels.get(market)
bench_prices = (
bench_panel[bench_ticker]
if bench_panel is not None and bench_ticker in bench_panel.columns
else pd.Series(dtype=float)
)
results = []
for path in state_files:
basename = os.path.basename(path)
parts = basename.replace(".json", "").split("_", 2)
if len(parts) < 3:
strategy_name = os.path.basename(path)[len(prefix):-len(".json")]
if strategy_name.startswith("sim_"):
continue
if wanted and strategy_name != wanted:
continue
if strategy_name in LIVE_EXCLUDED_STRATEGIES or not strategy_supports_market(strategy_name, market):
_log(f" Skipping {strategy_name}: not a live strategy in the {market.upper()} book")
continue
strategy_name = parts[2]
try:
with open(path) as f:
@@ -1025,19 +1056,22 @@ def cmd_live(args: argparse.Namespace) -> None:
_log(f" Skipping {path}: {e}")
continue
data_market = _strategy_data_market_safe(strategy_name, market)
price_book = latest_prices.get(data_market, {"asof": None, "prices": {}})
data_market = strategy_data_market(market, strategy_name)
price_book = _latest_prices(panels.get(data_market))
recorded = _last_recorded_prices(state)
holdings_raw = state.get("holdings", {}) or {}
cash = float(state.get("cash", 0) or 0)
# Per-holding marked-to-market table
holdings_detail = []
market_value = 0.0
for ticker, shares in holdings_raw.items():
shares_f = float(shares)
price = price_book["prices"].get(ticker)
value = shares_f * float(price) if price is not None else None
stale = price is None
if stale:
price = recorded.get(ticker)
value = shares_f * price if price is not None else None
if value is not None:
market_value += value
holdings_detail.append({
@@ -1045,28 +1079,28 @@ def cmd_live(args: argparse.Namespace) -> None:
"shares": shares_f,
"lastPrice": float(price) if price is not None else None,
"marketValue": round(value, 2) if value is not None else None,
"priceStale": stale,
})
holdings_detail.sort(
key=lambda h: (-(h["marketValue"] or 0.0), h["ticker"]),
)
holdings_detail.sort(key=lambda h: (-(h["marketValue"] or 0.0), h["ticker"]))
# Equity curve from persisted state
initial_capital = float(state.get("initial_capital", 0) or 0)
daily_equity = state.get("daily_equity", {}) or {}
dates = sorted(daily_equity.keys())
equity_curve = [
{"date": d, "value": round(float(daily_equity[d]), 2)}
for d in dates
]
equity_series = pd.Series(
{pd.Timestamp(d): float(daily_equity[d]) for d in dates}
).sort_index() if dates else pd.Series(dtype=float)
{pd.Timestamp(d): float(daily_equity[d]) for d in dates}, dtype=float,
).sort_index()
if len(equity_series) and initial_capital:
# Anchor at the initial capital one day before the first session so
# the first day's P&L is part of every metric.
anchor = equity_series.index[0] - pd.Timedelta(days=1)
equity_series = pd.concat([pd.Series({anchor: initial_capital}), equity_series])
equity_curve = [
{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
for d, v in equity_series.items()
]
strat_metrics = (
metrics.raw_summary(equity_series) if len(equity_series) >= 2 else None
)
strat_metrics = metrics.raw_summary(equity_series) if len(equity_series) >= 2 else None
# Trade-by-day aggregation (kept for backwards compatibility) plus
# full granular log.
trade_log_raw = state.get("trade_log", []) or []
trade_by_date: dict[str, dict] = {}
trade_log = []
@@ -1097,29 +1131,24 @@ def cmd_live(args: argparse.Namespace) -> None:
agg["sellTickers"].append(entry["ticker"])
trades = [{"date": d, **v} for d, v in sorted(trade_by_date.items())]
drawdowns = (
compute_drawdown_periods(equity_series) if len(equity_series) >= 2 else []
)
drawdowns = compute_drawdown_periods(equity_series) if len(equity_series) >= 2 else []
monthly_ret, yearly_ret = (
compute_periodic_returns(equity_series)
if len(equity_series) >= 2 else ({}, {})
compute_periodic_returns(equity_series) if len(equity_series) >= 2 else ({}, {})
)
meta = STRATEGY_META.get(
strategy_name, {"label": strategy_name, "category": "other"},
)
meta = STRATEGY_META.get(strategy_name, {"label": strategy_name, "category": "other"})
# Current value: prefer live MTM; fall back to last persisted equity.
live_value = cash + market_value if holdings_detail else cash
if not holdings_detail and equity_curve:
live_value = equity_curve[-1]["value"]
# Current value: live mark-to-market of holdings plus cash.
live_value = cash + market_value
if not holdings_detail and dates:
live_value = float(daily_equity[dates[-1]])
initial_capital = float(state.get("initial_capital", 0) or 0)
unrealized_pnl = (
live_value - initial_capital if initial_capital else 0.0
)
unrealized_pnl_pct = (
unrealized_pnl / initial_capital if initial_capital else 0.0
unrealized_pnl = live_value - initial_capital if initial_capital else 0.0
unrealized_pnl_pct = unrealized_pnl / initial_capital if initial_capital else 0.0
bench_curve = (
benchmark_curve(bench_prices, pd.DatetimeIndex(equity_series.index[1:]), initial_capital)
if len(equity_series) >= 2 and len(bench_prices) else []
)
results.append({
@@ -1127,7 +1156,9 @@ def cmd_live(args: argparse.Namespace) -> None:
"label": meta.get("label", strategy_name),
"category": meta.get("category", "other"),
"market": market,
"currency": MARKET_CURRENCY.get(market, "USD"),
"initialCapital": initial_capital,
"startDate": dates[0] if dates else None,
"currentValue": round(live_value, 2),
"cash": round(cash, 2),
"marketValue": round(market_value, 2),
@@ -1137,6 +1168,7 @@ def cmd_live(args: argparse.Namespace) -> None:
"holdingsDetail": holdings_detail,
"priceAsOf": price_book["asof"],
"equityCurve": equity_curve,
"benchmark": {"name": bench_label, "equityCurve": bench_curve},
"metrics": strat_metrics,
"drawdowns": drawdowns,
"trades": trades,
@@ -1154,7 +1186,7 @@ def cmd_live(args: argparse.Namespace) -> None:
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Quant bridge for stock-agent")
parser = argparse.ArgumentParser(description="Quant bridge for the web API")
subparsers = parser.add_subparsers(dest="command")
# list

View File

@@ -2,37 +2,81 @@
Persistent local data store for price history.
Layout:
data/us.csv — S&P 500 + SPY adjusted close prices
data/us_open.csv — S&P 500 + SPY adjusted open prices
data/cn.csv — CSI 300 + 000300.SS adjusted close prices
data/cn_open.csv — CSI 300 + 000300.SS adjusted open prices
data/{market}.csv — adjusted close prices
data/{market}_open.csv — adjusted open prices
data/{market}_unavailable.json — tickers Yahoo returned no data for (retried weekly)
On first run: downloads full 10-year history.
On subsequent runs: reads existing file, downloads only new dates, appends.
New tickers (index rebalance) are backfilled automatically.
Panels are stored raw: a date/ticker Yahoo has no bar for stays NaN, so a
missing print is distinguishable from an unchanged price. ``load()`` and
``update()`` forward-fill on read; ``load_raw()`` exposes the stored values.
Every ``update()`` re-downloads a trailing ``REFRESH_DAYS`` window and
overwrites it for the requested tickers. Partial intraday bars, late prints and
rows that turn out not to be trading days are therefore corrected on the next
run instead of being frozen. When Yahoo's split/dividend adjustment shifts the
overlap, that ticker's older history is rescaled so returns stay continuous.
"""
import fcntl
import json
import os
from contextlib import contextmanager
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
import yfinance as yf
DATA_DIR = "data"
# Trailing calendar days re-downloaded and overwritten on every update.
REFRESH_DAYS = 30
# A row is kept only if at least this fraction of tickers alive on that date
# have a bar. Drops e.g. US-holiday rows created by HK-listed tickers.
MIN_ROW_COVERAGE = 0.5
# A ticker counts as alive for this many rows after its last bar, so a newest
# row printed by only a few tickers (late data) is still judged sparse.
ALIVE_GRACE_ROWS = 10
# Relative change in the adjusted-price overlap that triggers a rescale.
RESCALE_TOLERANCE = 1e-3
UNAVAILABLE_RETRY_DAYS = 7
def _data_path(market: str, price_type: str = "close") -> str:
suffix = "" if price_type == "close" else f"_{price_type}"
return os.path.join(DATA_DIR, f"{market}{suffix}.csv")
def load(market: str, price_type: str = "close") -> pd.DataFrame | None:
"""Load existing data file, or None if it doesn't exist."""
def load_raw(market: str, price_type: str = "close") -> pd.DataFrame | None:
"""Load the stored panel without filling missing bars, or None if absent."""
path = _data_path(market, price_type)
if not os.path.exists(path):
return None
df = pd.read_csv(path, index_col=0, parse_dates=True)
return df
return pd.read_csv(path, index_col=0, parse_dates=True)
def load(market: str, price_type: str = "close") -> pd.DataFrame | None:
"""Load the stored panel with missing bars forward-filled."""
raw = load_raw(market, price_type)
return None if raw is None else raw.ffill()
def _write_atomic(df: pd.DataFrame, path: str) -> None:
tmp = f"{path}.tmp"
df.to_csv(tmp)
os.replace(tmp, path)
@contextmanager
def _panel_lock(market: str):
"""Serialize updates of one market panel across processes (monitor, bridge, scripts)."""
os.makedirs(DATA_DIR, exist_ok=True)
with open(os.path.join(DATA_DIR, f".{market}.lock"), "w") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(fh, fcntl.LOCK_UN)
def _download(tickers: list[str], start: str, end: str | None = None,
@@ -73,38 +117,6 @@ def _download(tickers: list[str], start: str, end: str | None = None,
return result
def _download_period(tickers: list[str], period: str,
fields: list[str] | None = None) -> dict[str, pd.DataFrame]:
"""Download using period-based API (for small gaps)."""
if fields is None:
fields = ["Close"]
raw = yf.download(tickers, period=period, auto_adjust=True)
if raw.empty:
return {f: pd.DataFrame() for f in fields}
result = {}
for field in fields:
if field in raw.columns.get_level_values(0) if isinstance(raw.columns, pd.MultiIndex) else field in raw.columns:
selected = raw[field]
if isinstance(selected, pd.Series):
result[field] = selected.to_frame(name=tickers[0])
else:
result[field] = selected
else:
result[field] = pd.DataFrame()
return result
def _clean(data: pd.DataFrame) -> pd.DataFrame:
"""Drop tickers with >50% missing data, forward-fill, drop all-NaN rows."""
good = data.columns[data.notna().mean() > 0.5]
dropped = set(data.columns) - set(good)
if dropped:
print(f"--- Dropped {len(dropped)} tickers with >50% missing data ---")
data = data[good]
data = data.ffill().dropna(how="all")
return data
def _clean_market_data(data: pd.DataFrame, field: str) -> pd.DataFrame:
"""Clean market data while preserving volume gaps."""
good = data.columns[data.notna().mean() > 0.5]
@@ -165,145 +177,210 @@ def update_market_data(market: str, tickers: list[str], fields: list[str]) -> di
return cleaned
def _unavailable_path(market: str) -> str:
return os.path.join(DATA_DIR, f"{market}_unavailable.json")
def _load_unavailable(market: str) -> dict[str, str]:
try:
with open(_unavailable_path(market)) as f:
return json.load(f)
except (OSError, ValueError):
return {}
def _save_unavailable(market: str, unavailable: dict[str, str]) -> None:
with open(_unavailable_path(market), "w") as f:
json.dump(unavailable, f, indent=0, sort_keys=True)
def _drop_sparse_rows(df: pd.DataFrame) -> pd.DataFrame:
"""Drop rows where too few of the tickers alive on that date have a bar."""
df = df.dropna(how="all")
if df.empty:
return df
notna = df.notna().to_numpy()
n = len(df)
has_any = notna.any(axis=0)
first = np.where(has_any, notna.argmax(axis=0), n)
last = np.where(has_any, n - 1 - notna[::-1].argmax(axis=0), -1)
pos = np.arange(n)[:, None]
alive = (pos >= first) & (pos <= last + ALIVE_GRACE_ROWS)
coverage = notna.sum(axis=1) / np.maximum(alive.sum(axis=1), 1)
keep = coverage >= MIN_ROW_COVERAGE
if not keep.all():
dropped = [str(d.date()) for d in df.index[~keep]]
print(f"--- Dropped {len(dropped)} sparse non-trading rows: {dropped[:5]} ---")
return df[keep]
def _adjustment_ratio(old: pd.Series, new: pd.Series) -> float | None:
"""Factor to rescale older history when Yahoo re-adjusted the overlap window.
Uses the median ratio over the earliest overlapping bars so a couple of
bad stored values cannot fake an adjustment.
"""
both = pd.concat([old, new], axis=1, keys=["old", "new"]).dropna()
both = both[both["old"] > 0].head(5)
if len(both) < 2:
return None
ratio = float((both["new"] / both["old"]).median())
if not np.isfinite(ratio) or abs(ratio - 1.0) <= RESCALE_TOLERANCE:
return None
return ratio
def _merge_refresh(existing: pd.DataFrame | None, new: pd.DataFrame,
window_start: pd.Timestamp | None) -> pd.DataFrame:
"""Overwrite requested tickers from ``window_start`` on with freshly downloaded bars."""
new = new.dropna(axis=1, how="all")
if existing is None or existing.empty:
return _drop_sparse_rows(new.sort_index())
out = existing.copy()
if window_start is None:
window_start = new.index.min() if not new.empty else out.index.max()
rescaled = []
for col in new.columns:
if col not in out.columns:
continue
ratio = _adjustment_ratio(out.loc[out.index >= window_start, col], new[col])
if ratio is not None:
out.loc[out.index < window_start, col] *= ratio
rescaled.append(f"{col}x{ratio:.4f}")
if rescaled:
print(f"--- Rescaled history for re-adjusted tickers: {rescaled[:8]} ---")
out = out.reindex(index=out.index.union(new.index),
columns=list(out.columns) + [c for c in new.columns if c not in out.columns])
window = out.index >= window_start
out.loc[window, new.columns] = new.reindex(out.index[window]).to_numpy()
return _drop_sparse_rows(out.sort_index())
def _fill_open(close: pd.DataFrame, open_raw: pd.DataFrame | None) -> pd.DataFrame:
"""Align open prices to the close panel; fall back to close where open is missing."""
if open_raw is None or open_raw.empty:
return close.copy()
aligned = open_raw.reindex(index=close.index, columns=close.columns).ffill()
return aligned.fillna(close)
def update(market: str, tickers: list[str],
with_open: bool = False) -> pd.DataFrame | tuple[pd.DataFrame, pd.DataFrame]:
"""
Load existing data, download new dates and new tickers, save back.
Refresh the stored panel for ``tickers`` and return it forward-filled.
Parameters
----------
with_open : bool
If True, also download/maintain Open prices and return (close, open) tuple.
- First run: downloads 10 years of history.
- Every run: re-downloads the last ``REFRESH_DAYS`` calendar days for the
requested tickers already in the panel and overwrites them.
- New tickers are backfilled over the panel's full date range; tickers
Yahoo has no data for are remembered and retried weekly.
Returns
-------
pd.DataFrame or (close_df, open_df)
Price data ready for backtesting.
Returns the close panel, or ``(close, open)`` when ``with_open`` is True.
"""
os.makedirs(DATA_DIR, exist_ok=True)
fields = ["Close", "Open"] if with_open else ["Close"]
with _panel_lock(market):
close_raw = load_raw(market, "close")
open_raw = load_raw(market, "open") if with_open else None
unavailable = _load_unavailable(market)
today = datetime.now()
existing_close = load(market, "close")
existing_open = load(market, "open") if with_open else None
if close_raw is None:
start = (today - timedelta(days=365 * 10)).strftime("%Y-%m-%d")
downloaded = _download(tickers, start=start, fields=fields)
close_raw = _merge_refresh(None, downloaded["Close"], None)
if with_open:
open_raw = _merge_refresh(None, downloaded.get("Open", pd.DataFrame()), None)
else:
if with_open and open_raw is None:
print("--- Downloading Open prices for the full panel (first time) ---")
start = close_raw.index[0].strftime("%Y-%m-%d")
opens = _download(list(close_raw.columns), start=start, fields=["Open"])
open_raw = _merge_refresh(None, opens["Open"], None)
# If we need open prices but don't have them cached, force a full re-download
need_full_open = with_open and existing_open is None and existing_close is not None
if existing_close is None:
# First run: download full 10-year history
start = (datetime.now() - timedelta(days=365 * 10)).strftime("%Y-%m-%d")
downloaded = _download(tickers, start=start, fields=fields)
close_data = downloaded["Close"]
open_data = downloaded.get("Open", pd.DataFrame()) if with_open else None
elif need_full_open:
# Have close data but need open data — download open prices for full range
close_data = existing_close
start = close_data.index[0].strftime("%Y-%m-%d")
known_tickers = [t for t in close_data.columns if t in tickers]
print(f"--- Downloading Open prices for {len(known_tickers)} tickers (first time) ---")
downloaded = _download(known_tickers, start=start, fields=["Open"])
open_data = downloaded.get("Open", pd.DataFrame())
last_date = close_data.index[-1]
# Still need to append new close dates and backfill new tickers
next_day = (last_date + timedelta(days=1)).strftime("%Y-%m-%d")
today = datetime.now().strftime("%Y-%m-%d")
if next_day < today:
if known_tickers:
gap_days = (datetime.now() - last_date.to_pydatetime().replace(tzinfo=None)).days
if gap_days <= 7:
print(f"--- Fetching last {gap_days + 5}d to append ---")
new = _download_period(known_tickers, f"{gap_days + 5}d", fields=fields)
new_close = new["Close"]
new_close = new_close[new_close.index > last_date]
known = [t for t in tickers if t in close_raw.columns]
if known:
window_start = close_raw.index[-1] - pd.Timedelta(days=REFRESH_DAYS)
fresh = _download(known, start=window_start.strftime("%Y-%m-%d"), fields=fields)
if not fresh["Close"].empty:
close_raw = _merge_refresh(close_raw, fresh["Close"], window_start)
if with_open:
open_raw = _merge_refresh(open_raw, fresh.get("Open", pd.DataFrame()), window_start)
else:
new = _download(known_tickers, start=next_day, fields=fields)
new_close = new["Close"]
if not new_close.empty:
close_data = pd.concat([close_data, new_close]).sort_index()
close_data = close_data[~close_data.index.duplicated(keep="last")]
print(f"--- Appended {len(new_close)} new days ---")
else:
print("--- No new trading days to append ---")
print("--- Refresh download returned no data; keeping stored panel ---")
new_tickers = [t for t in tickers if t not in close_data.columns]
if new_tickers:
start = close_data.index[0].strftime("%Y-%m-%d")
backfill = _download(new_tickers, start=start, fields=fields)
backfill_close = backfill["Close"]
backfill_open = backfill.get("Open", pd.DataFrame())
if not backfill_close.empty:
backfill_close = backfill_close.reindex(close_data.index)
close_data = pd.concat([close_data, backfill_close], axis=1)
if not backfill_open.empty and open_data is not None:
backfill_open = backfill_open.reindex(open_data.index)
open_data = pd.concat([open_data, backfill_open], axis=1)
print(f"--- Backfilled {len(new_tickers)} new tickers ---")
else:
close_data = existing_close
open_data = existing_open if with_open else None
last_date = close_data.index[-1]
cutoff = (today - timedelta(days=UNAVAILABLE_RETRY_DAYS)).strftime("%Y-%m-%d")
new_tickers = [t for t in tickers
if t not in close_raw.columns and unavailable.get(t, "") < cutoff]
if new_tickers:
start = close_raw.index[0].strftime("%Y-%m-%d")
backfill = _download(new_tickers, start=start, fields=fields)
downloaded_any = not backfill["Close"].dropna(how="all").empty
got = backfill["Close"].dropna(axis=1, how="all")
got = got.reindex(index=close_raw.index)
got = got.dropna(axis=1, how="all")
for t in new_tickers:
if t in got.columns:
unavailable.pop(t, None)
elif downloaded_any:
# Only a ticker missing from an otherwise successful
# download is unavailable; a failed request is retried.
unavailable[t] = today.strftime("%Y-%m-%d")
if not got.empty:
close_raw = pd.concat([close_raw, got], axis=1)
if with_open and open_raw is not None:
opens = backfill.get("Open", pd.DataFrame())
opens = opens.reindex(index=open_raw.index, columns=got.columns)
open_raw = pd.concat([open_raw, opens], axis=1)
print(f"--- Backfilled {got.shape[1]} new tickers ---")
missing = len(new_tickers) - got.shape[1]
if missing:
print(f"--- {missing} tickers have no Yahoo data; retry after "
f"{UNAVAILABLE_RETRY_DAYS} days ---")
# 1) Append new dates for existing tickers
next_day = (last_date + timedelta(days=1)).strftime("%Y-%m-%d")
today = datetime.now().strftime("%Y-%m-%d")
if next_day < today:
known_tickers = [t for t in close_data.columns if t in tickers]
if known_tickers:
gap_days = (datetime.now() - last_date.to_pydatetime().replace(tzinfo=None)).days
if gap_days <= 7:
print(f"--- Fetching last {gap_days + 5}d to append ---")
new = _download_period(known_tickers, f"{gap_days + 5}d", fields=fields)
new_close = new["Close"]
new_close = new_close[new_close.index > last_date]
new_open = new.get("Open", pd.DataFrame())
if not new_open.empty:
new_open = new_open[new_open.index > last_date]
else:
new = _download(known_tickers, start=next_day, fields=fields)
new_close = new["Close"]
new_open = new.get("Open", pd.DataFrame())
close_raw = close_raw.loc[:, ~close_raw.columns.duplicated()]
_write_atomic(close_raw, _data_path(market, "close"))
print(f"--- Saved {close_raw.shape[0]} days x {close_raw.shape[1]} tickers to "
f"{_data_path(market, 'close')} ---")
if with_open and open_raw is not None:
open_raw = open_raw.loc[:, ~open_raw.columns.duplicated()]
_write_atomic(open_raw, _data_path(market, "open"))
_save_unavailable(market, unavailable)
if not new_close.empty:
close_data = pd.concat([close_data, new_close]).sort_index()
close_data = close_data[~close_data.index.duplicated(keep="last")]
print(f"--- Appended {len(new_close)} new days ---")
if with_open and not new_open.empty and open_data is not None:
open_data = pd.concat([open_data, new_open]).sort_index()
open_data = open_data[~open_data.index.duplicated(keep="last")]
else:
print("--- No new trading days to append ---")
close = close_raw.ffill()
if with_open:
return close, _fill_open(close, open_raw)
return close
# 2) Backfill any new tickers not in existing data
new_tickers = [t for t in tickers if t not in close_data.columns]
if new_tickers:
start = close_data.index[0].strftime("%Y-%m-%d")
backfill = _download(new_tickers, start=start, fields=fields)
backfill_close = backfill["Close"]
backfill_open = backfill.get("Open", pd.DataFrame())
if not backfill_close.empty:
backfill_close = backfill_close.reindex(close_data.index)
close_data = pd.concat([close_data, backfill_close], axis=1)
print(f"--- Backfilled {len(new_tickers)} new tickers ---")
if with_open and not backfill_open.empty and open_data is not None:
backfill_open = backfill_open.reindex(open_data.index)
open_data = pd.concat([open_data, backfill_open], axis=1)
# Clean and save close
close_data = _clean(close_data)
close_path = _data_path(market, "close")
close_data.to_csv(close_path)
print(f"--- Saved {close_data.shape[0]} days x {close_data.shape[1]} tickers to {close_path} ---")
def rebuild(market: str, with_open: bool = True, years: int = 10) -> None:
"""Re-download the full history of every ticker in a stored panel.
if with_open and open_data is not None and not open_data.empty:
# Align open data to same tickers/dates as close
open_data = open_data.reindex(index=close_data.index, columns=close_data.columns)
open_data = open_data.ffill().dropna(how="all")
# Where open is missing, fall back to close
open_data = open_data.fillna(close_data)
open_path = _data_path(market, "open")
open_data.to_csv(open_path)
print(f"--- Saved {open_data.shape[0]} days x {open_data.shape[1]} tickers to {open_path} ---")
return close_data, open_data
return close_data
Used to repair panels written by the old append-only updater. Tickers
Yahoo no longer serves keep their stored history.
"""
with _panel_lock(market):
old_close = load_raw(market, "close")
old_open = load_raw(market, "open")
if old_close is None:
raise FileNotFoundError(_data_path(market, "close"))
tickers = list(old_close.columns)
start = (datetime.now() - timedelta(days=365 * years)).strftime("%Y-%m-%d")
fields = ["Close", "Open"] if with_open else ["Close"]
fresh = _download(tickers, start=start, fields=fields)
close = _merge_refresh(None, fresh["Close"], None)
kept = [t for t in tickers if t not in close.columns]
if kept:
close = close.combine_first(old_close[kept].reindex(close.index))
print(f"--- Kept stored history for {len(kept)} tickers Yahoo no longer serves ---")
close = close[[t for t in tickers if t in close.columns]]
_write_atomic(close, _data_path(market, "close"))
print(f"--- Rebuilt {market}: {close.shape[0]} days x {close.shape[1]} tickers ---")
if with_open:
opens = fresh.get("Open", pd.DataFrame()).dropna(axis=1, how="all")
opens = opens.reindex(index=close.index)
if old_open is not None:
missing = [t for t in close.columns if t not in opens.columns and t in old_open.columns]
if missing:
opens = pd.concat([opens, old_open[missing].reindex(close.index)], axis=1)
_write_atomic(opens, _data_path(market, "open"))

23
deploy/start-monitor.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Long-running live-trader monitor (US + CN, all strategies).
# Started by the quant-trading-monitor.service systemd --user unit; replaces the
# old "tmux new -s quant; uv run python trader.py monitor" workflow.
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
cd "$PROJECT_ROOT"
# uv lives in ~/.local/bin, which is not on systemd's default PATH.
export PATH="/home/gahow/.local/bin:$PATH"
# Market data is fetched through the lab proxy (same as interactive `proxyOn`).
# QUANT_MARKET_PROXY lives in the untracked repo-root .env.
if [[ -f .env ]]; then
set -a; source .env; set +a
fi
if [[ -n "${QUANT_MARKET_PROXY:-}" ]]; then
export http_proxy="$QUANT_MARKET_PROXY" https_proxy="$QUANT_MARKET_PROXY" all_proxy="$QUANT_MARKET_PROXY"
fi
exec uv run python trader.py monitor

14
deploy/start-web-api.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../web" && pwd)"
cd "${ROOT_DIR}"
export PATH="/home/gahow/.local/bin:/home/gahow/.nvm/versions/node/v24.13.1/bin:$PATH"
export HOST="127.0.0.1"
export PORT="${PORT:-8787}"
# Node's built-in fetch does not read HTTPS_PROXY unless this opt-in is enabled.
# The configured Nasdaq/Yahoo data sources require the host's local proxy.
export NODE_USE_ENV_PROXY="${NODE_USE_ENV_PROXY:-1}"
exec npx tsx src/server/index.ts

11
deploy/start-web-vite.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../web" && pwd)"
cd "${ROOT_DIR}"
export PATH="/home/gahow/.nvm/versions/node/v24.13.1/bin:$PATH"
# The public HTTPS entrypoint is nginx on :5443. Keep Vite as a localhost-only
# upstream so the development server is not exposed as a second LAN entrypoint.
exec npx vite --host 127.0.0.1 --port 5174

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Quant trading web API server
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/gahow/projects/quant-trading/web
ExecStart=/home/gahow/projects/quant-trading/deploy/start-web-api.sh
Restart=always
RestartSec=3
Environment=NODE_ENV=production
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Quant live trader monitor (US + CN, all strategies)
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/gahow/projects/quant-trading
ExecStart=/home/gahow/projects/quant-trading/deploy/start-monitor.sh
Restart=always
RestartSec=10
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Quant trading web frontend (Vite dev server behind nginx)
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/gahow/projects/quant-trading/web
ExecStart=/home/gahow/projects/quant-trading/deploy/start-web-vite.sh
Restart=always
RestartSec=3
[Install]
WantedBy=default.target

26
main.py
View File

@@ -34,6 +34,7 @@ def backtest(
fee_base: float = 0.0,
fee_per_share: float = 0.0,
open_data: pd.DataFrame | None = None,
min_trade_value: float = 0.0,
) -> pd.Series:
"""
Vectorized backtest.
@@ -60,6 +61,11 @@ def backtest(
IBKR-style schedule: max(2, 1.88 + 0.009 * shares).
open_data : pd.DataFrame, optional
Open prices. When provided, enables open-to-close execution mode.
min_trade_value : float
Per-ticker rebalances smaller than ``max(min_trade_value, 0.1% of
equity)`` are not charged the per-trade fee, mirroring the live
trader, which never places such orders. 0 (default) charges every
weight change.
Returns
-------
@@ -98,6 +104,11 @@ def backtest(
weight_changes = positions.diff().fillna(0.0)
equity_running = (1 + portfolio_returns).cumprod() * initial_capital
eq_prev = equity_running.shift(1).fillna(initial_capital)
if min_trade_value > 0:
threshold = np.maximum(min_trade_value, 0.001 * eq_prev.abs())
charged = weight_changes.abs().mul(eq_prev.abs(), axis=0).ge(threshold, axis=0)
else:
charged = weight_changes.abs() > 1e-8
if fee_per_share > 0:
# Convert per-ticker weight change into share count traded.
@@ -107,20 +118,27 @@ def backtest(
shares_traded = dollar_traded.div(data).replace(
[np.inf, -np.inf], 0.0,
).fillna(0.0)
per_trade_fee = (fee_base + fee_per_share * shares_traded).clip(
# The per-share schedule applies to US-listed shares only.
per_share = pd.Series(fee_per_share, index=data.columns)
per_share[[c for c in data.columns if str(c).endswith(".HK")]] = 0.0
per_trade_fee = (fee_base + shares_traded.mul(per_share, axis=1)).clip(
lower=fixed_fee,
)
trade_mask = weight_changes.abs() > 1e-8
per_trade_fee = per_trade_fee.where(trade_mask, 0.0)
per_trade_fee = per_trade_fee.where(charged, 0.0)
daily_fee = per_trade_fee.sum(axis=1)
else:
n_trades = (weight_changes.abs() > 1e-8).sum(axis=1)
n_trades = charged.sum(axis=1)
daily_fee = n_trades * fixed_fee
fee_impact = daily_fee / eq_prev
portfolio_returns -= fee_impact
equity = (1 + portfolio_returns).cumprod() * initial_capital
# A portfolio that loses everything stays at zero; compounding negative
# equity would otherwise produce meaningless values and metrics.
ruined = (equity <= 0).cummax()
if ruined.any():
equity = equity.where(~ruined, 0.0)
return equity

View File

@@ -7,8 +7,16 @@ def total_return(equity: pd.Series) -> float:
def annualized_return(equity: pd.Series, periods: int = 252) -> float:
n = len(equity)
return (1 + total_return(equity)) ** (periods / n) - 1
"""CAGR. Uses elapsed calendar time when the index is dated, so gaps in a
live record (missed days) do not inflate the annualized figure."""
growth = 1 + total_return(equity)
if growth <= 0:
return -1.0
if isinstance(equity.index, pd.DatetimeIndex) and len(equity) > 1:
years = (equity.index[-1] - equity.index[0]).days / 365.25
if years > 0:
return growth ** (1 / years) - 1
return growth ** (periods / len(equity)) - 1
def annualized_volatility(returns: pd.Series, periods: int = 252) -> float:

419
precompute_backtest.py Normal file
View File

@@ -0,0 +1,419 @@
"""
Pre-compute backtest results for all strategies, for multiple capital scenarios.
Each strategy is backtested ONCE per market with that market's live starting
capital (MONITOR_CAPITAL: US $10k, CN ¥70k); the
resulting equity series is then replayed with different cash-injection
schedules so we get multiple scenarios without re-running each strategy.
Usage:
uv run python precompute_backtest.py [--output PATH] [--years N]
Output JSON shape:
{
"computedAt": "...",
"scenarios": {
"lump_sum_10k": {label, capital, injectionSchedule, markets: {us, cn}},
"dca_feb_aug": {label, capital, injectionSchedule, markets: {us, cn}}
}
}
"""
import argparse
import json
import os
import sys
from datetime import datetime
import numpy as np
import pandas as pd
import data_manager
import metrics
import universe_history as uh
from bridge import (
MIN_TRADE_VALUE,
STRATEGY_META,
_build_strategy_result,
_sanitize_for_json,
benchmark_curve,
compute_rebalance_trades,
)
from main import backtest
from trader import (
strategy_extra_tickers,
strategy_supports_market,
ETF_STRATEGY_UNIVERSES,
MONITOR_CAPITAL,
STRATEGY_REGISTRY,
convert_quote_currency,
filter_tradable_tickers,
price_request,
)
from universe import UNIVERSES
# Initial capital per market, in local currency — the same amounts the live
# monitor starts with (US $10k, CN ¥70k ≈ $10k). Scenario amounts below are
# multiples of it, so both markets model the same real money.
MARKET_CAPITAL = dict(MONITOR_CAPITAL)
SCENARIOS = [
{
"id": "lump_sum_10k",
"label": "起始资金一次性投入",
"description": "US $10,000 / CN ¥70,000 一次性投入,无后续追加",
"injection": None,
},
{
"id": "dca_feb_aug",
"label": "起始资金 + 每年 2/8 月追加一半",
"description": "US $10,000 + 每年 2/8 月各 $5,000;CN ¥70,000 + 每年 2/8 月各 ¥35,000",
"injection": {
"amount_ratio": 0.5,
"frequency": "yearly_months",
"months": [2, 8],
"startYear": 1,
},
},
]
def _injection_for_market(injection: dict | None, capital: float) -> dict | None:
"""Materialize a scenario's injection schedule in a market's currency."""
if not injection:
return None
out = {k: v for k, v in injection.items() if k != "amount_ratio"}
out["amount"] = round(capital * injection["amount_ratio"], 2)
return out
def log(msg: str) -> None:
print(f"[precompute] {msg}", file=sys.stderr)
def _equity_series_from_curve(curve: list[dict]) -> pd.Series:
"""Reconstruct a pandas Series from a precomputed equity curve list."""
if not curve:
return pd.Series(dtype=float)
dates = pd.to_datetime([p["date"] for p in curve])
values = [float(p["value"]) for p in curve]
return pd.Series(values, index=dates)
def run_market_raw(
market: str,
strategy_names: list[str],
years: int,
capital: float,
tx_cost: float,
fixed_fee: float,
fee_base: float = 0.0,
fee_per_share: float = 0.0,
) -> dict | None:
"""Run all strategies once; return raw equity series + metadata per strategy."""
stock_strategies = []
etf_strategies = []
for name in strategy_names:
base_name = name.removeprefix("sim_")
if base_name in ETF_STRATEGY_UNIVERSES:
etf_strategies.append(name)
else:
stock_strategies.append(name)
raw_strategies: list[dict] = []
benchmark_label = "SPY"
bench_curve = []
start_date = None
end_date = None
# --- Stock strategies ---
if stock_strategies:
universe = UNIVERSES[market]
tickers = universe["fetch"]()
benchmark_ticker = universe["benchmark"]
benchmark_label = universe["benchmark_label"]
pit_intervals = None
if market == "us":
pit_intervals = uh.load_sp500_history()
historical_tickers = uh.all_tickers_ever(pit_intervals)
all_tickers = sorted(set(tickers + historical_tickers + [benchmark_ticker])
| {t for n in stock_strategies for t in strategy_extra_tickers(n)})
log(f"PIT universe: {len(all_tickers)} tickers")
else:
all_tickers = sorted(set(tickers + [benchmark_ticker])
| {t for n in stock_strategies for t in strategy_extra_tickers(n)})
log(f"WARNING: {market.upper()} lacks PIT history; using current constituents")
log(f"Fetching price data for {len(all_tickers)} tickers...")
data = data_manager.update(market, all_tickers, with_open=False)
if isinstance(data, tuple):
data = data[0]
if years:
cutoff = data.index[-1] - pd.DateOffset(years=years)
data = data[data.index >= cutoff]
if pit_intervals is not None:
log("Applying PIT membership mask...")
data = uh.mask_prices(data, pit_intervals)
tickers = [
t for t in data.columns
if t != benchmark_ticker and data[t].notna().any()
]
else:
tickers = filter_tradable_tickers(data, tickers)
log(f"Universe: {len(tickers)} stocks, {data.index[0].date()} to {data.index[-1].date()}")
for name in stock_strategies:
log(f" Running: {name}")
try:
strategy = STRATEGY_REGISTRY[name]()
# Mixed stock+ETF strategies also read their fixed ETFs.
cols = tickers + [t for t in strategy_extra_tickers(name)
if t in data.columns and t not in tickers]
equity = backtest(
strategy, data[cols],
initial_capital=capital,
transaction_cost=tx_cost,
fixed_fee=fixed_fee,
fee_base=fee_base,
fee_per_share=fee_per_share,
min_trade_value=MIN_TRADE_VALUE,
)
weights = strategy.generate_signals(data[cols])
weights = weights.reindex(data[cols].index).fillna(0.0)
trade_events = compute_rebalance_trades(weights)
raw_strategies.append({"name": name, "equity": equity, "trades": trade_events})
log(f" OK: {name} (final: ${equity.iloc[-1]:,.0f})")
except Exception as e:
log(f" FAILED: {name}: {e}")
# Benchmark on exactly the strategies' dates
bench_curve = benchmark_curve(data[benchmark_ticker], data.index, capital)
start_date = data.index[0].strftime("%Y-%m-%d")
end_date = data.index[-1].strftime("%Y-%m-%d")
# --- ETF strategies ---
if etf_strategies:
for name in etf_strategies:
log(f" Running ETF strategy: {name}")
try:
base_name = name.removeprefix("sim_")
etf_tickers = ETF_STRATEGY_UNIVERSES[base_name]
all_etf = sorted(set(etf_tickers + ["SPY"]))
etf_data = data_manager.update("etfs", price_request(market, all_etf), with_open=False)
if isinstance(etf_data, tuple):
etf_data = etf_data[0]
etf_data = convert_quote_currency(etf_data)
if years:
cutoff = etf_data.index[-1] - pd.DateOffset(years=years)
etf_data = etf_data[etf_data.index >= cutoff]
strategy = STRATEGY_REGISTRY[name]()
tradable = filter_tradable_tickers(etf_data, etf_tickers)
equity = backtest(
strategy, etf_data[tradable],
initial_capital=capital,
transaction_cost=tx_cost,
fixed_fee=fixed_fee,
fee_base=fee_base,
fee_per_share=fee_per_share,
min_trade_value=MIN_TRADE_VALUE,
)
weights = strategy.generate_signals(etf_data[tradable])
weights = weights.reindex(etf_data[tradable].index).fillna(0.0)
trade_events = compute_rebalance_trades(weights)
raw_strategies.append({"name": name, "equity": equity, "trades": trade_events})
log(f" OK: {name} (final: ${equity.iloc[-1]:,.0f})")
except Exception as e:
log(f" FAILED: {name}: {e}")
if not stock_strategies:
benchmark_label = "SPY (Benchmark)"
spy_data = data_manager.update("etfs", ["SPY"], with_open=False)
if isinstance(spy_data, tuple):
spy_data = spy_data[0]
if years:
cutoff = spy_data.index[-1] - pd.DateOffset(years=years)
spy_data = spy_data[spy_data.index >= cutoff]
if "SPY" in spy_data.columns:
bench_curve = benchmark_curve(spy_data["SPY"], spy_data.index, capital)
start_date = spy_data.index[0].strftime("%Y-%m-%d")
end_date = spy_data.index[-1].strftime("%Y-%m-%d")
if not raw_strategies:
return None
return {
"market": market,
"strategy_runs": raw_strategies,
"benchmark_label": benchmark_label,
"benchmark_curve": bench_curve,
"start_date": start_date,
"end_date": end_date,
"tx_cost": tx_cost,
"fixed_fee": fixed_fee,
}
def build_scenario_for_market(
raw: dict,
scenario: dict,
years: int,
capital: float,
) -> dict:
"""Materialize a scenario (with optional injections) for one market."""
injection_schedule = _injection_for_market(scenario.get("injection"), capital)
benchmark_curve = raw["benchmark_curve"]
benchmark_label = raw["benchmark_label"]
strategy_results = []
for run in raw["strategy_runs"]:
equity = run["equity"]
try:
result = _build_strategy_result(
run["name"], equity, run["trades"], injection_schedule, capital,
)
strategy_results.append(result)
except Exception as e:
log(f" [scenario={scenario['id']}] FAILED {run['name']}: {e}")
# Re-scale benchmark for DCA scenarios so the comparison stays apples-to-apples.
final_bench_curve = benchmark_curve
if injection_schedule and benchmark_curve:
bench_series = _equity_series_from_curve(benchmark_curve)
bench_returns = bench_series.pct_change().fillna(0.0)
from bridge import build_injection_schedule
from research.permanent_yearly import equity_with_cashflows
contribs, _ = build_injection_schedule(bench_series, injection_schedule)
bench_with_inj = equity_with_cashflows(bench_returns, contribs, capital)
final_bench_curve = [
{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
for d, v in bench_with_inj.items()
]
return {
"meta": {
"market": raw["market"],
"years": years,
"initialCapital": capital,
"startDate": raw["start_date"],
"endDate": raw["end_date"],
"txCost": raw["tx_cost"],
"fixedFee": raw["fixed_fee"],
"injectionSchedule": injection_schedule,
},
"strategies": strategy_results,
"benchmark": {
"name": benchmark_label,
"equityCurve": final_bench_curve,
},
}
def main():
parser = argparse.ArgumentParser(description="Pre-compute backtest results")
parser.add_argument(
"--output", default=None,
help="Output directory (default: data/precomputed, read by the web UI)",
)
parser.add_argument(
"--years", type=int, default=10,
help="Backtest period in years (default: 10)",
)
args = parser.parse_args()
output_dir = args.output or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "data", "precomputed"
)
os.makedirs(output_dir, exist_ok=True)
us_strategies = []
cn_strategies = []
for name in STRATEGY_REGISTRY:
meta = STRATEGY_META.get(name, {"markets": ["us", "cn"]})
markets = [m for m in meta.get("markets", ["us", "cn"]) if strategy_supports_market(name, m)]
if "us" in markets:
us_strategies.append(name)
if "cn" in markets:
cn_strategies.append(name)
us_capital = MARKET_CAPITAL["us"]
cn_capital = MARKET_CAPITAL["cn"]
# Run each market once (cached price data → fast subsequent runs across scenarios).
log("=" * 60)
log(f"US MARKET: {len(us_strategies)} strategies @ ${us_capital:,.0f}")
log("=" * 60)
# US fee schedule (IBKR-style tiered):
# fee = max(2.0, 1.88 + 0.009 * shares)
us_raw = run_market_raw(
"us", us_strategies,
years=args.years, capital=us_capital,
tx_cost=0.001, fixed_fee=2.0,
fee_base=1.88, fee_per_share=0.009,
)
log("=" * 60)
log(f"CN MARKET: {len(cn_strategies)} strategies @ ¥{cn_capital:,.0f}")
log("=" * 60)
cn_raw = run_market_raw(
"cn", cn_strategies,
years=args.years, capital=cn_capital,
tx_cost=0.001, fixed_fee=5.0,
)
# Materialize every scenario × market by applying its injection schedule.
scenarios_out: dict[str, dict] = {}
for scenario in SCENARIOS:
log(f"--- scenario: {scenario['id']} ---")
scenarios_out[scenario["id"]] = {
"id": scenario["id"],
"label": scenario["label"],
"description": scenario["description"],
"capital": us_capital,
"capitalByMarket": {"us": us_capital, "cn": cn_capital},
"injectionSchedule": _injection_for_market(scenario["injection"], us_capital),
"markets": {},
}
if us_raw:
scenarios_out[scenario["id"]]["markets"]["us"] = build_scenario_for_market(
us_raw, scenario, args.years, us_capital
)
if cn_raw:
scenarios_out[scenario["id"]]["markets"]["cn"] = build_scenario_for_market(
cn_raw, scenario, args.years, cn_capital
)
output = {
"computedAt": datetime.now().isoformat(),
"config": {"years": args.years, "initialCapital": MARKET_CAPITAL},
"scenarios": scenarios_out,
}
output_path = os.path.join(output_dir, "backtest_results.json")
with open(output_path, "w") as f:
json.dump(_sanitize_for_json(output), f, default=str)
file_size = os.path.getsize(output_path) / (1024 * 1024)
log("=" * 60)
log(f"DONE: Written to {output_path} ({file_size:.1f} MB)")
for sid, sc in scenarios_out.items():
for mk, mv in sc["markets"].items():
log(f" [{sid}] {mk.upper()}: {len(mv['strategies'])} strategies")
log("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,5 @@
[project]
name = "quant"
name = "quant-trading"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"

116
scripts/run_daily.py Normal file
View File

@@ -0,0 +1,116 @@
"""Daily live-trading update for all registered strategies.
Iterates STRATEGY_REGISTRY and calls trader.cmd_auto for each, sharing
fetched price panels across strategies (we still call cmd_auto so the
state-file format stays consistent with manual runs).
Usage:
uv run python scripts/run_daily.py [--market us|cn|all] [--capital 10000]
[--only NAME ...] [--skip NAME ...]
Designed for cron:
# 5pm ET weekdays, after US close
0 17 * * 1-5 cd /path/to/quant && uv run python scripts/run_daily.py --market us
# 4pm CST weekdays, after CN close
0 16 * * 1-5 cd /path/to/quant && uv run python scripts/run_daily.py --market cn
"""
from __future__ import annotations
import argparse
import os
import sys
import time
import types
from datetime import datetime
# Make the project root importable when invoked as `python scripts/run_daily.py`
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
# We change into the project root so trader's relative `data/` paths resolve.
os.chdir(PROJECT_ROOT)
from trader import ( # noqa: E402
ETF_STRATEGY_UNIVERSES,
STRATEGY_REGISTRY,
cmd_auto,
)
from bridge import STRATEGY_META # noqa: E402
def _strategy_markets(name: str) -> list[str]:
"""Return the markets a strategy targets (per its META declaration)."""
meta = STRATEGY_META.get(name, {"markets": ["us"]})
return list(meta.get("markets") or ["us"])
def _make_args(market: str, name: str, capital: float) -> argparse.Namespace:
"""Build the argparse namespace cmd_auto expects."""
return argparse.Namespace(
market=market,
strategy=name,
capital=capital,
tx_cost=0.001,
fixed_fee=0.0, # cmd_auto falls back to MARKET_FEES + tier
integer_shares=True, # match the existing live state convention
)
def main() -> int:
parser = argparse.ArgumentParser(description="Daily live-trading update")
parser.add_argument("--market", default="all",
help="Which market(s): 'us', 'cn', 'all' (default: all)")
parser.add_argument("--capital", type=float, default=10_000,
help="Initial capital for any newly-initialized strategy (default: 10000)")
parser.add_argument("--only", nargs="+", default=None,
help="If set, only run these strategy names")
parser.add_argument("--skip", nargs="+", default=None,
help="Strategy names to skip (e.g. broken ones)")
parser.add_argument("--dry-run", action="store_true",
help="Print what would be run without executing")
args = parser.parse_args()
markets = ["us", "cn"] if args.market == "all" else [args.market]
only = set(args.only) if args.only else None
skip = set(args.skip) if args.skip else set()
plan: list[tuple[str, str]] = []
for name in STRATEGY_REGISTRY:
if only and name not in only:
continue
if name in skip:
continue
for mk in _strategy_markets(name):
if mk in markets:
plan.append((mk, name))
print(f"[run_daily] {datetime.now().isoformat(timespec='seconds')} "
f"will process {len(plan)} (market, strategy) pairs")
if args.dry_run:
for mk, name in plan:
print(f" [DRY] {mk:<3} {name}")
return 0
failures = []
t0 = time.time()
for mk, name in plan:
ns = _make_args(mk, name, args.capital)
try:
cmd_auto(ns)
except SystemExit:
raise
except Exception as e: # noqa: BLE001
failures.append((mk, name, repr(e)))
print(f"[run_daily] FAILED {mk} {name}: {e}", file=sys.stderr)
elapsed = time.time() - t0
print(f"[run_daily] done in {elapsed:.1f}s — {len(plan) - len(failures)} ok, {len(failures)} failed")
for mk, name, err in failures:
print(f" fail {mk} {name}: {err}", file=sys.stderr)
return 0 if not failures else 1
if __name__ == "__main__":
sys.exit(main())

21
scripts/run_daily.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Daily live-trading update entrypoint for cron.
#
# Examples:
# 0 17 * * 1-5 /path/to/quant/scripts/run_daily.sh us >> /var/log/quant_us.log 2>&1
# 0 16 * * 1-5 /path/to/quant/scripts/run_daily.sh cn >> /var/log/quant_cn.log 2>&1
#
# The script changes into the quant project root and calls run_daily.py via uv.
# Defaults to "all" markets when no argument is passed.
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
cd "$PROJECT_ROOT"
MARKET="${1:-all}"
shift || true
exec uv run python scripts/run_daily.py --market "$MARKET" "$@"

128
tests/test_live_pipeline.py Normal file
View File

@@ -0,0 +1,128 @@
"""Contracts of the live pipeline: price panel refresh, session gating,
order sizing, and how results are reported."""
import unittest
from datetime import datetime, timezone
import numpy as np
import pandas as pd
import bridge
import data_manager
import trader
from main import backtest
def _panel(rows: dict[str, list], start: str = "2026-09-14") -> pd.DataFrame:
idx = pd.bdate_range(start, periods=len(next(iter(rows.values()))))
return pd.DataFrame(rows, index=idx, dtype=float)
class PanelRefreshTests(unittest.TestCase):
def test_refresh_overwrites_stale_window_and_keeps_missing_bars_raw(self):
stored = _panel({"AAA": [10, 11, 11, 11, 12], "BBB": [5, 5, 5, 5, 5]})
window_start = stored.index[2]
fresh = pd.DataFrame(
{"AAA": [11.5, np.nan, 12.2]}, index=stored.index[2:],
)
merged = data_manager._merge_refresh(stored, fresh, window_start)
self.assertEqual(merged.loc[stored.index[2], "AAA"], 11.5)
self.assertTrue(np.isnan(merged.loc[stored.index[3], "AAA"]))
self.assertEqual(merged.loc[stored.index[4], "AAA"], 12.2)
# Tickers not in the refresh keep their stored values.
self.assertEqual(merged.loc[stored.index[3], "BBB"], 5)
def test_refresh_rescales_history_after_split_adjustment(self):
stored = _panel({"AAA": [100, 102, 104, 106, 108, 110, 112]})
window_start = stored.index[2]
# Yahoo now reports the whole window divided by 2 (2:1 split adjusted).
fresh = stored.loc[window_start:, ["AAA"]] / 2
merged = data_manager._merge_refresh(stored, fresh, window_start)
returns = merged["AAA"].pct_change().dropna()
self.assertTrue((returns > 0).all(), returns)
self.assertAlmostEqual(merged["AAA"].iloc[0], 50.0)
def test_newest_row_printed_by_few_tickers_is_dropped(self):
panel = _panel({
"A": [1, 1, 1, np.nan], "B": [1, 1, 1, np.nan],
"C": [1, 1, 1, np.nan], "D": [1, 1, 1, 2],
})
self.assertNotIn(panel.index[-1], data_manager._drop_sparse_rows(panel).index)
def test_rows_printed_by_a_minority_of_tickers_are_dropped(self):
panel = _panel({
"SPY": [1, 1, np.nan, 1, 1],
"UPRO": [1, 1, np.nan, 1, 1],
"GLD": [1, 1, np.nan, 1, 1],
"7200.HK": [1, 1, 1, 1, 1],
})
cleaned = data_manager._drop_sparse_rows(panel)
self.assertNotIn(panel.index[2], cleaned.index)
self.assertEqual(len(cleaned), 4)
class SessionAndOrderTests(unittest.TestCase):
def test_todays_bar_is_not_a_close_until_the_session_settles(self):
raw = _panel({"SPY": [1, 1, 1]}, start="2026-09-22")
today = raw.index[-1]
intraday = datetime(2026, 9, 24, 17, 0, tzinfo=timezone.utc) # 13:00 New York
settled = datetime(2026, 9, 24, 20, 30, tzinfo=timezone.utc) # 16:30 New York
self.assertNotIn(today, trader.completed_sessions(raw, "us", "SPY", now=intraday))
self.assertIn(today, trader.completed_sessions(raw, "us", "SPY", now=settled))
def test_days_without_a_benchmark_print_are_not_sessions(self):
raw = _panel({"SPY": [1, np.nan, 1], "7200.HK": [1, 1, 1]}, start="2026-09-02")
later = datetime(2026, 10, 1, tzinfo=timezone.utc)
sessions = trader.completed_sessions(raw, "etfs", "SPY", now=later)
self.assertNotIn(raw.index[1], sessions)
def test_a_share_orders_use_board_lots_and_exit_odd_lots(self):
trades = trader.compute_trades(
holdings={"600000.SS": 150}, cash=100_000.0,
target_weights={"600519.SS": 0.5}, prices={"600519.SS": 170.0, "600000.SS": 10.0},
min_trade_value=50, integer_shares=True, lot_size=100,
)
by_ticker = {t["ticker"]: t["shares_delta"] for t in trades}
self.assertEqual(by_ticker["600519.SS"] % 100, 0)
self.assertEqual(by_ticker["600000.SS"], -150)
def test_tickers_without_a_fresh_price_are_not_traded(self):
trades = trader.compute_trades(
holdings={"UPRO": 10}, cash=5_000.0, target_weights={"TQQQ": 1.0},
prices={"UPRO": 150.0, "TQQQ": 80.0}, frozen={"UPRO"},
)
self.assertEqual([t["ticker"] for t in trades], ["TQQQ"])
class ReportingTests(unittest.TestCase):
def test_periodic_returns_include_the_first_day_of_each_period(self):
idx = pd.to_datetime(["2026-01-30", "2026-02-02", "2026-02-27", "2026-03-02"])
equity = pd.Series([100.0, 110.0, 110.0, 121.0], index=idx)
monthly, yearly = bridge.compute_periodic_returns(equity)
self.assertAlmostEqual(monthly["2026-02"], 0.10)
self.assertAlmostEqual(monthly["2026-03"], 0.10)
self.assertAlmostEqual(yearly["2026"], 0.21)
def test_injected_cash_is_not_reported_as_performance(self):
idx = pd.bdate_range("2024-01-02", periods=400)
equity = pd.Series(10_000 * 1.0003 ** np.arange(400), index=idx)
schedule = {"amount": 5_000, "frequency": "yearly_months", "months": [2, 8], "startYear": 1}
plain = bridge._build_strategy_result("x", equity, [], None, 10_000)
injected = bridge._build_strategy_result("x", equity, [], schedule, 10_000)
self.assertAlmostEqual(injected["metrics"]["totalReturn"], plain["metrics"]["totalReturn"])
flows = injected["capitalFlows"]
self.assertEqual(flows["invested"], 10_000 + 5_000 * len(injected["injections"]))
self.assertAlmostEqual(flows["profit"], flows["finalValue"] - flows["invested"], places=2)
def test_ruined_portfolio_stays_at_zero(self):
class TwiceLevered:
def generate_signals(self, data):
return pd.DataFrame(2.0, index=data.index, columns=data.columns)
prices = pd.DataFrame({"X": [10.0, 10.0, 4.0, 5.0]}, index=pd.bdate_range("2026-01-01", periods=4))
equity = backtest(TwiceLevered(), prices, initial_capital=100)
self.assertTrue((equity.iloc[2:] == 0).all(), equity)
if __name__ == "__main__":
unittest.main()

598
trader.py
View File

@@ -31,7 +31,9 @@ State is persisted in data/trader_{market}_{strategy}.json between runs.
import argparse
import json
import os
from datetime import datetime, timedelta
import sys
import zoneinfo
from datetime import datetime, timedelta, timezone
import numpy as np
import pandas as pd
@@ -89,6 +91,28 @@ MARKET_FEE_TIERED = {
"us": {"fee_base": 1.88, "fee_per_share": 0.009},
}
# Minimum tradable lot per market when trading whole shares. A-shares trade in
# board lots of 100.
MARKET_LOT_SIZE = {"us": 1, "cn": 100}
# Default live capital per market, in the market's own currency.
MONITOR_CAPITAL = {"us": 10_000.0, "cn": 70_000.0}
# Exchange close per price panel. A bar dated today is treated as a closing
# price only once the session has closed and settled for a few minutes.
MARKET_SESSION = {
"us": ("America/New_York", 16, 0),
"etfs": ("America/New_York", 16, 0),
"cn": ("Asia/Shanghai", 15, 0),
}
SESSION_SETTLE_MINUTES = 20
# HK-listed tickers held in a USD book are converted with this FX series
# (HKD per 1 USD). HKD is pegged within 7.75-7.85, so the band midpoint is
# used on dates the series has no quote (Yahoo's FX feed is intermittent).
HKD_FX_TICKER = "HKD=X"
HKD_PEG_MIDPOINT = 7.80
# ---------------------------------------------------------------------------
# Strategy registry
# ---------------------------------------------------------------------------
@@ -233,7 +257,28 @@ MIXED_STRATEGY_EXTRA_TICKERS = {
"trend_rider_v6_top10": sorted(set(ETF_UNIVERSE)),
}
DEFAULT_MONITOR_STRATEGIES = list(STRATEGY_REGISTRY.keys())
# Registered strategies the live monitor does not run, with the reason.
LIVE_EXCLUDED_STRATEGIES = {
"buy_and_hold": "equal weight across ~500 names is not investable with whole "
"shares at live capital (every slice is below the minimum trade)",
}
DEFAULT_MONITOR_STRATEGIES = [
name for name in STRATEGY_REGISTRY if name not in LIVE_EXCLUDED_STRATEGIES
]
def strategy_extra_tickers(strategy_name: str) -> list[str]:
"""Fixed ETF tickers a mixed stock+ETF strategy needs beside its stock universe."""
return list(MIXED_STRATEGY_EXTRA_TICKERS.get(strategy_name.removeprefix("sim_"), []))
def strategy_supports_market(strategy_name: str, market: str) -> bool:
"""ETF-based strategies trade USD-listed ETFs, so they only run in the US book."""
base_name = strategy_name.removeprefix("sim_")
if base_name in ETF_STRATEGY_UNIVERSES or base_name in MIXED_STRATEGY_EXTRA_TICKERS:
return market == "us"
return True
def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
@@ -268,6 +313,83 @@ def filter_tradable_tickers(price_data: pd.DataFrame, tickers: list[str]) -> lis
return [t for t in tickers if t in price_data.columns]
def _hk_columns(columns) -> list[str]:
return [c for c in columns if str(c).endswith(".HK")]
def convert_quote_currency(panel: pd.DataFrame) -> pd.DataFrame:
"""Express HK-listed tickers of a USD panel in USD using the HKD=X series."""
hk = _hk_columns(panel.columns)
if not hk:
return panel
if HKD_FX_TICKER in panel.columns:
fx = panel[HKD_FX_TICKER].ffill().bfill().fillna(HKD_PEG_MIDPOINT)
else:
print(f" WARNING: {HKD_FX_TICKER} not in panel; converting HK prices at "
f"the peg midpoint {HKD_PEG_MIDPOINT}", file=sys.stderr)
fx = pd.Series(HKD_PEG_MIDPOINT, index=panel.index)
out = panel.copy()
out[hk] = out[hk].div(fx, axis=0)
return out
def price_request(market: str, tickers: list[str]) -> list[str]:
"""Tickers to download for a USD panel, including FX needed for conversion."""
request = sorted(set(tickers))
if market != "cn" and _hk_columns(request):
request.append(HKD_FX_TICKER)
return request
def load_strategy_prices(market: str, strategy_name: str, tickers: list[str],
benchmark: str, with_open: bool = False, refresh: bool = True):
"""Load a strategy's prices in its portfolio currency, refreshing them first
unless ``refresh`` is False (the panel is then read as stored).
Returns ``(close, open_or_None, close_raw)``. ``close_raw`` keeps NaN
where the exchange printed no bar that day, so callers can distinguish a
stale forward-filled price from a real one.
"""
data_market = strategy_data_market(market, strategy_name)
if refresh:
request = price_request(market, list(tickers) + [benchmark])
result = data_manager.update(data_market, request, with_open=with_open)
close, open_data = result if with_open else (result, None)
else:
close = data_manager.load(data_market, "close")
open_data = (data_manager._fill_open(close, data_manager.load_raw(data_market, "open"))
if with_open else None)
close_raw = data_manager.load_raw(data_market, "close").reindex(
index=close.index, columns=close.columns,
)
close = convert_quote_currency(close)
close_raw = convert_quote_currency(close_raw)
if open_data is not None:
open_data = convert_quote_currency(open_data)
return close, open_data, close_raw
def completed_sessions(close_raw: pd.DataFrame, data_market: str, benchmark: str,
now: datetime | None = None) -> pd.DatetimeIndex:
"""Trading days whose closing prices are final.
Stored panel rows are trading days (sparse rows are dropped on update).
The ETF panel mixes US and HK listings, so there a day also needs a
benchmark (SPY) print. Today's bar is excluded until the exchange has
closed and settled, because before that it is an intraday snapshot.
"""
index = close_raw.index
if data_market == "etfs" and benchmark in close_raw.columns:
index = index[close_raw[benchmark].notna().to_numpy()]
tz_name, hour, minute = MARKET_SESSION.get(data_market, MARKET_SESSION["us"])
now_local = (now or datetime.now(timezone.utc)).astimezone(zoneinfo.ZoneInfo(tz_name))
today = pd.Timestamp(now_local.date())
settled = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(
minutes=SESSION_SETTLE_MINUTES,
)
return index[index < today] if now_local < settled else index[index <= today]
# ---------------------------------------------------------------------------
# Persistent state
# ---------------------------------------------------------------------------
@@ -358,21 +480,26 @@ def generate_target_weights(strategy, open_data: pd.DataFrame, target_date) -> d
def compute_trades(holdings: dict, cash: float, target_weights: dict,
prices: dict, min_trade_value: float = 50.0,
integer_shares: bool = False) -> list[dict]:
integer_shares: bool = False, lot_size: int = 1,
frozen: set | None = None) -> list[dict]:
"""
Compute trades needed to move from current holdings to target weights.
If integer_shares=True, share deltas are rounded to whole numbers and
buys are capped so they don't exceed available cash (accounting for
commissions). Sells are processed first to free up cash.
If integer_shares=True, share deltas are rounded toward zero to whole
multiples of ``lot_size`` (e.g. 100-share A-share board lots); a position
whose target is zero is always sold in full, odd lot included.
Tickers in ``frozen`` (no fresh price today) are neither bought nor sold.
Returns list of {ticker, shares_delta, direction, est_value}.
"""
total = portfolio_value(holdings, prices, cash)
raw = []
frozen = frozen or set()
all_tickers = set(list(target_weights.keys()) + list(holdings.keys()))
for ticker in sorted(all_tickers):
if ticker in frozen:
continue
target_weight = target_weights.get(ticker, 0.0)
price = prices.get(ticker, 0.0)
if price <= 0:
@@ -384,11 +511,11 @@ def compute_trades(holdings: dict, cash: float, target_weights: dict,
delta = target_shares - current_shares
if integer_shares:
# Round toward zero for buys (floor), away from zero for sells (floor of abs)
if delta > 0:
delta = int(delta) # floor: don't overshoot cash
if target_weight <= 0 and current_shares > 0:
delta = -current_shares # full exit may include an odd lot
else:
delta = -int(abs(delta)) # floor of magnitude: don't over-sell
# Round toward zero: never overshoot cash or over-sell.
delta = int(delta / lot_size) * lot_size
trade_value = abs(delta * price)
if trade_value < min_trade_value:
@@ -398,10 +525,10 @@ def compute_trades(holdings: dict, cash: float, target_weights: dict,
raw.append({
"ticker": ticker,
"shares_delta": int(delta) if integer_shares else round(delta, 4),
"shares_delta": int(round(delta)) if integer_shares else round(delta, 4),
"direction": "BUY" if delta > 0 else "SELL",
"est_value": round(trade_value, 2),
"price": round(price, 2),
"price": round(price, 6),
"target_shares": int(round(target_shares)) if integer_shares else round(target_shares, 4),
"current_shares": int(current_shares) if integer_shares else round(current_shares, 4),
})
@@ -433,7 +560,8 @@ def _per_trade_commission(
def execute_trades(state: dict, trades: list[dict], prices: dict,
tx_cost: float = 0.001, fixed_fee: float = 0.0,
fee_base: float = 0.0, fee_per_share: float = 0.0,
trade_date: str = "", integer_shares: bool = False) -> None:
trade_date: str = "", integer_shares: bool = False,
lot_size: int = 1) -> None:
"""Execute trades: update holdings and cash in state, append to trade_log.
When integer_shares=True, sells are executed first to free up cash,
@@ -451,11 +579,15 @@ def execute_trades(state: dict, trades: list[dict], prices: dict,
buys = [t for t in trades if t["shares_delta"] > 0]
ordered = sells + buys if integer_shares else trades
us_fee_per_share = fee_per_share
for trade in ordered:
ticker = trade["ticker"]
delta = trade["shares_delta"]
price = prices.get(ticker, trade["price"])
cost = abs(delta * price)
# The per-share schedule applies to US-listed shares only; HK lines
# (often < $1/share) pay the proportional cost and the ticket floor.
fee_per_share = 0.0 if ticker.endswith(".HK") else us_fee_per_share
commission = _per_trade_commission(
abs(delta), price, tx_cost, fixed_fee, fee_base, fee_per_share,
)
@@ -469,6 +601,7 @@ def execute_trades(state: dict, trades: list[dict], prices: dict,
if affordable_price_unit <= 0:
continue
affordable = int((cash - max(fixed_fee, fee_base)) / affordable_price_unit)
affordable = affordable // lot_size * lot_size
if affordable < 1:
continue
delta = affordable
@@ -492,7 +625,7 @@ def execute_trades(state: dict, trades: list[dict], prices: dict,
"action": "BUY" if delta > 0 else "SELL",
"ticker": ticker,
"shares": abs(delta) if integer_shares else round(abs(delta), 4),
"price": round(price, 2),
"price": round(price, 6),
"value": round(cost, 2),
"commission": round(commission, 2),
})
@@ -522,7 +655,7 @@ def record_daily_snapshot(state: dict, date_str: str, prices: dict,
p = prices.get(ticker, 0.0)
positions[ticker] = {
"shares": shares,
"price": round(p, 2),
"price": round(p, 6),
"value": round(shares * p, 2),
}
@@ -534,7 +667,7 @@ def record_daily_snapshot(state: dict, date_str: str, prices: dict,
"action": t["direction"],
"ticker": t["ticker"],
"shares": abs(t["shares_delta"]),
"price": round(p, 2),
"price": round(p, 6),
"value": round(abs(t["shares_delta"]) * p, 2),
})
@@ -567,6 +700,108 @@ def get_prices_for_date(tickers: list[str], date_idx, price_df: pd.DataFrame) ->
return {t: float(row[t]) for t in tickers if t in row.index and pd.notna(row[t])}
def _with_last_known_prices(state: dict, prices: dict) -> dict:
"""Fill prices of held tickers missing from the panel with their last recorded price.
Without this a holding that drops out of the price panel would be valued
at zero and could never be sold.
"""
missing = [t for t in state["holdings"] if t not in prices]
if not missing:
return prices
out = dict(prices)
for entry in reversed(state.get("daily_log", [])):
for ticker in missing:
if ticker not in out:
price = entry.get("holdings", {}).get(ticker, {}).get("price")
if price:
out[ticker] = float(price)
if all(t in out for t in missing):
break
still = [t for t in missing if t not in out]
print(f" WARNING: no panel price for held {missing}; "
f"{'unvalued: ' + str(still) if still else 'using last recorded prices'}")
return out
def _strategy_fees(args, market: str) -> dict:
tier = MARKET_FEE_TIERED.get(market, {})
return {
"tx_cost": args.tx_cost,
"fixed_fee": args.fixed_fee if args.fixed_fee > 0 else MARKET_FEES.get(market, 0.0),
"fee_base": tier.get("fee_base", 0.0),
"fee_per_share": tier.get("fee_per_share", 0.0),
}
def _signals_for_sessions(strategy_name: str, open_data: pd.DataFrame,
days: pd.DatetimeIndex) -> pd.DataFrame:
"""Target weights for many sessions in one pass: row ``d`` uses opens up to ``d``.
Equivalent to ``generate_target_weights`` per day for causal strategies
(weights at t never depend on data after t), which every registered
strategy must be.
"""
hist = open_data.loc[:days[-1]]
dummy = pd.DataFrame([hist.iloc[-1].values], index=[hist.index[-1] + pd.Timedelta(days=1)],
columns=hist.columns)
weights = STRATEGY_REGISTRY[strategy_name]().generate_signals(pd.concat([hist, dummy]))
return weights.shift(-1).reindex(days).fillna(0.0)
def _run_session(state: dict, strategy_name: str, market: str, day: pd.Timestamp,
tickers: list[str], benchmark: str, close_data: pd.DataFrame,
open_data: pd.DataFrame, close_raw: pd.DataFrame, fees: dict,
integer_shares: bool, signal_row: pd.Series | None = None) -> dict:
"""Signal on ``day``'s open, execute at ``day``'s close, record the day.
Tickers without a real closing print on ``day`` (forward-filled price) are
valued but not traded. ``signal_row`` supplies precomputed target weights.
"""
day_str = str(day.date())
lot_size = MARKET_LOT_SIZE.get(market, 1) if integer_shares else 1
if signal_row is None:
strategy = STRATEGY_REGISTRY[strategy_name]()
target_weights = generate_target_weights(strategy, open_data.loc[:day, tickers], day)
else:
target_weights = {t: float(w) for t, w in signal_row.items() if w > 1e-6}
all_held = sorted(set(target_weights) | set(state["holdings"]))
close_prices = _with_last_known_prices(
state, get_prices_for_date(all_held + [benchmark], day, close_data),
)
raw_row = close_raw.loc[day]
frozen = {t for t in all_held if t not in raw_row.index or pd.isna(raw_row[t])}
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
trades = compute_trades(
state["holdings"], state["cash"], target_weights, close_prices,
min_trade_value=max(50, pre_value * 0.001),
integer_shares=integer_shares, lot_size=lot_size, frozen=frozen,
)
n_log = len(state["trade_log"])
execute_trades(state, trades, close_prices, trade_date=day_str,
integer_shares=integer_shares, lot_size=lot_size, **fees)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
previous = [d for d in sorted(state["daily_equity"]) if d < day_str]
prev_eq = state["daily_equity"][previous[-1]] if previous else state["initial_capital"]
state["daily_equity"][day_str] = round(post_value, 2)
record_daily_snapshot(state, day_str, close_prices, trades, prev_eq)
state["last_morning"] = day_str
state["last_evening"] = day_str
state["pending_trades"] = None
return {
"date": day_str,
"value": post_value,
"daily_return": post_value / prev_eq - 1 if prev_eq else 0.0,
"trades": len(state["trade_log"]) - n_log,
"commission": sum(t["commission"] for t in state["trade_log"][n_log:]),
"frozen": sorted(t for t in frozen if t in state["holdings"] or t in target_weights),
"positions": len(state["holdings"]),
}
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
@@ -575,18 +810,23 @@ def cmd_morning(args):
"""Morning: download open prices, generate today's trade orders."""
market = args.market
strategy_name = args.strategy
if not strategy_supports_market(strategy_name, market):
print(f"{strategy_name} does not run in the {market.upper()} book; skipping.")
return
tickers, benchmark = strategy_universe(market, strategy_name)
data_market = strategy_data_market(market, strategy_name)
all_tickers = sorted(set(tickers + [benchmark]))
# Load or init state
state = load_state(market, strategy_name)
if not state:
state = init_state(market, strategy_name, args.capital)
print(f"--- Initialized new portfolio: ${args.capital:,.0f} cash ---")
capital = args.capital or MONITOR_CAPITAL.get(market, 10_000.0)
state = init_state(market, strategy_name, capital)
print(f"--- Initialized new portfolio: {capital:,.0f} cash ---")
# Download data (close + open)
close_data, open_data = data_manager.update(data_market, all_tickers, with_open=True)
close_data, open_data, _ = load_strategy_prices(
market, strategy_name, tickers, benchmark, with_open=True,
refresh=getattr(args, "refresh", True),
)
tickers = filter_tradable_tickers(close_data, tickers)
today = open_data.index[-1]
@@ -601,7 +841,7 @@ def cmd_morning(args):
print(f"{'='*60}")
# Run strategy on open prices
strategy = STRATEGY_REGISTRY[strategy_name](top_n=max(5, len(tickers) // 10))
strategy = STRATEGY_REGISTRY[strategy_name]()
target_weights = generate_target_weights(strategy, open_data[tickers], today)
# Get today's open prices for trade computation
@@ -613,7 +853,8 @@ def cmd_morning(args):
total = portfolio_value(state["holdings"], open_prices, state["cash"])
trades = compute_trades(state["holdings"], state["cash"], target_weights,
open_prices, min_trade_value=max(50, total * 0.001),
integer_shares=args.integer_shares)
integer_shares=args.integer_shares,
lot_size=MARKET_LOT_SIZE.get(market, 1))
# Store pending
state["pending_trades"] = {
@@ -665,8 +906,6 @@ def cmd_evening(args):
market = args.market
strategy_name = args.strategy
tickers, benchmark = strategy_universe(market, strategy_name)
data_market = strategy_data_market(market, strategy_name)
all_tickers = sorted(set(tickers + [benchmark]))
state = load_state(market, strategy_name)
if not state:
@@ -685,7 +924,7 @@ def cmd_evening(args):
return
# Get close prices
close_data = data_manager.update(data_market, all_tickers)
close_data, _, _ = load_strategy_prices(market, strategy_name, tickers, benchmark)
tickers = filter_tradable_tickers(close_data, tickers)
target_date = pd.Timestamp(trade_date)
@@ -693,7 +932,9 @@ def cmd_evening(args):
list(pending.get("target_weights", {}).keys()) +
list(state["holdings"].keys())
))
close_prices = get_prices_for_date(all_held, target_date, close_data)
close_prices = _with_last_known_prices(
state, get_prices_for_date(all_held, target_date, close_data),
)
# Recompute trades at close prices for accurate execution
trades = pending.get("trades", [])
@@ -708,19 +949,15 @@ def cmd_evening(args):
# Recompute trades with close prices for execution
target_weights = pending.get("target_weights", {})
lot_size = MARKET_LOT_SIZE.get(market, 1)
exec_trades = compute_trades(
state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares
integer_shares=args.integer_shares, lot_size=lot_size,
)
fixed_fee = args.fixed_fee if args.fixed_fee > 0 else MARKET_FEES.get(args.market, 0.0)
tier = MARKET_FEE_TIERED.get(args.market, {})
execute_trades(state, exec_trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=fixed_fee,
fee_base=tier.get("fee_base", 0.0),
fee_per_share=tier.get("fee_per_share", 0.0),
trade_date=trade_date, integer_shares=args.integer_shares)
execute_trades(state, exec_trades, close_prices, trade_date=trade_date,
integer_shares=args.integer_shares, lot_size=lot_size,
**_strategy_fees(args, market))
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][trade_date] = round(post_value, 2)
@@ -772,13 +1009,13 @@ def cmd_status(args):
# Get latest prices
tickers, benchmark = strategy_universe(market, strategy_name)
data_market = strategy_data_market(market, strategy_name)
all_tickers = sorted(set(tickers + [benchmark]))
close_data = data_manager.update(data_market, all_tickers)
close_data, _, _ = load_strategy_prices(market, strategy_name, tickers, benchmark)
last_date = close_data.index[-1]
all_held = list(state["holdings"].keys())
prices = get_prices_for_date(all_held + [benchmark], last_date, close_data)
prices = _with_last_known_prices(
state, get_prices_for_date(all_held + [benchmark], last_date, close_data),
)
total = portfolio_value(state["holdings"], prices, state["cash"])
pnl = total - state["initial_capital"]
@@ -1077,11 +1314,11 @@ def cmd_simulate(args):
market = args.market
strategy_name = args.strategy
tickers, benchmark = strategy_universe(market, strategy_name)
data_market = strategy_data_market(market, strategy_name)
all_tickers = sorted(set(tickers + [benchmark]))
# Load both open and close data
close_data, open_data = data_manager.update(data_market, all_tickers, with_open=True)
close_data, open_data, _ = load_strategy_prices(
market, strategy_name, tickers, benchmark, with_open=True,
)
tickers = filter_tradable_tickers(close_data, tickers)
# Date range
@@ -1098,7 +1335,7 @@ def cmd_simulate(args):
# Pre-compute strategy weights for the full period (much faster than per-day)
# Append dummy row so shift(1) inside strategy produces valid last-day signal
strategy = STRATEGY_REGISTRY[strategy_name](top_n=max(5, len(tickers) // 10))
strategy = STRATEGY_REGISTRY[strategy_name]()
open_tickers = open_data[tickers]
dummy_date = open_tickers.index[-1] + pd.Timedelta(days=1)
dummy_row = pd.DataFrame(
@@ -1143,15 +1380,17 @@ def cmd_simulate(args):
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
# Compute and execute trades at close prices
lot_size = MARKET_LOT_SIZE.get(market, 1)
trades = compute_trades(
state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares
integer_shares=args.integer_shares, lot_size=lot_size,
)
execute_trades(state, trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
trade_date=day_str, integer_shares=args.integer_shares)
trade_date=day_str, integer_shares=args.integer_shares,
lot_size=lot_size)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][day_str] = round(post_value, 2)
@@ -1288,8 +1527,10 @@ def cmd_monitor(args):
# Banner
print(f"\n{'='*60}")
print(f" MONITOR MODE — {len(markets)} market(s), "
f"{len(strategies)} strategies each")
print(f" Capital: ${args.capital:,.0f} | "
f"{len(strategies)} strategies")
capital_note = (f"{args.capital:,.0f}" if args.capital else
", ".join(f"{m.upper()} {MONITOR_CAPITAL[m]:,.0f}" for m in markets))
print(f" Capital (new portfolios): {capital_note} | "
f"Integer shares: {args.integer_shares}")
for mkt, sched in market_schedules.items():
fee = MARKET_FEES.get(mkt, args.fixed_fee)
@@ -1336,7 +1577,7 @@ def cmd_monitor(args):
import importlib
import trader as _self_mod
importlib.reload(_self_mod)
current = list(_self_mod.STRATEGY_REGISTRY.keys())
current = list(_self_mod.DEFAULT_MONITOR_STRATEGIES)
return current, _self_mod.STRATEGY_REGISTRY
def _run_phase(market, phase, now_utc):
@@ -1365,8 +1606,15 @@ def cmd_monitor(args):
print(f"[monitor] {'='*55}")
market_fee = MARKET_FEES.get(market, args.fixed_fee)
for strat_name in strategies:
eligible = [s for s in strategies if strategy_supports_market(s, market)]
# One download per price panel per phase; strategies then read offline.
try:
refresh_panels(market, eligible, label="monitor")
except Exception as e:
print(f"[monitor] Panel refresh failed ({e}); using stored prices")
for strat_name in eligible:
sub_args = copy.copy(args)
sub_args.refresh = False
sub_args.strategy = strat_name
sub_args.market = market
sub_args.fixed_fee = market_fee
@@ -1376,15 +1624,10 @@ def cmd_monitor(args):
if phase == "morning":
cmd_morning(sub_args)
else:
state = load_state(market, strat_name)
today_str = now_local.strftime("%Y-%m-%d")
if (state and state.get("last_morning") == today_str
and state.get("pending_trades")):
cmd_evening(sub_args)
else:
print(f"[monitor] Morning not run for "
f"{market.upper()}:{strat_name} — using auto")
cmd_auto(sub_args)
# Evening always goes through auto: it re-derives the open
# signal, executes at the final close and catches up any
# sessions missed while the monitor was down.
cmd_auto(sub_args)
print(f"[monitor] {market.upper()}:{strat_name} {phase} OK")
except KeyboardInterrupt:
raise
@@ -1395,7 +1638,13 @@ def cmd_monitor(args):
traceback.print_exc()
print(f"[monitor] {market.upper()} {phase} done — "
f"{len(strategies)} strategies\n")
f"{len(eligible)} strategies\n")
# Catch up sessions missed while the monitor was down. cmd_auto only
# processes sessions whose close is final, so this is safe at any time.
for mkt in markets:
print(f"[monitor] Startup catch-up for {mkt.upper()}")
_run_phase(mkt, "evening", datetime.now(utc))
while True:
now_utc = datetime.now(utc)
@@ -1458,118 +1707,133 @@ def cmd_monitor(args):
print()
def refresh_panels(market: str, strategy_names: list[str], label: str) -> None:
"""Refresh every price panel the strategies read, once, for the union of their tickers."""
requests: dict[str, set[str]] = {}
for name in strategy_names:
tickers, benchmark = strategy_universe(market, name)
requests.setdefault(strategy_data_market(market, name), set()).update(
price_request(market, list(tickers) + [benchmark]))
for data_market, request in requests.items():
print(f"[{label}] Refreshing {data_market} panel for {len(request)} tickers")
data_manager.update(data_market, sorted(request), with_open=True)
def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = None,
label: str = "auto", refresh: bool = True) -> dict | None:
"""Run every completed session not yet recorded in ``state`` (catch-up safe).
A fresh portfolio starts at ``start`` when given, otherwise at the most
recent completed session. Returns the updated state, or None if skipped.
"""
market = args.market
strategy_name = args.strategy
if not strategy_supports_market(strategy_name, market):
print(f"[{label}] {strategy_name} does not run in the {market.upper()} book; skipping.")
return None
tickers, benchmark = strategy_universe(market, strategy_name)
close_data, open_data, close_raw = load_strategy_prices(
market, strategy_name, tickers, benchmark, with_open=True, refresh=refresh,
)
tickers = filter_tradable_tickers(close_data, tickers)
sessions = completed_sessions(close_raw, strategy_data_market(market, strategy_name), benchmark)
if len(sessions) == 0:
print(f"[{label}] No completed session in the price panel.")
return None
if not state:
capital = args.capital or MONITOR_CAPITAL.get(market, 10_000.0)
state = init_state(market, strategy_name, capital)
first = start if start is not None else sessions[-1]
todo = sessions[sessions >= first]
print(f"[{label}] Initialized {strategy_name} ({market.upper()}) with {capital:,.0f} cash")
else:
last = state.get("last_evening")
todo = sessions[sessions > pd.Timestamp(last)] if last else sessions[-1:]
if len(todo) == 0:
print(f"[{label}] {strategy_name}: {state.get('last_evening')} already processed.")
return state
if len(todo) > 1:
print(f"[{label}] {strategy_name}: processing {len(todo)} sessions "
f"{todo[0].date()} .. {todo[-1].date()}")
fees = _strategy_fees(args, market)
signals = (_signals_for_sessions(strategy_name, open_data[tickers], todo)
if len(todo) > 1 else None)
for day in todo:
r = _run_session(state, strategy_name, market, day, tickers, benchmark,
close_data, open_data, close_raw, fees, args.integer_shares,
signal_row=signals.loc[day] if signals is not None else None)
frozen = f" | no fresh price: {', '.join(r['frozen'])}" if r["frozen"] else ""
print(f"[{label}] {r['date']} {strategy_name:<34} value {r['value']:>12,.2f} "
f"({r['daily_return']:+.2%}) trades {r['trades']:>2} "
f"fee {r['commission']:>6.2f} positions {r['positions']:>2}{frozen}")
return state
def cmd_auto(args):
"""
Automated daily run — single invocation handles both morning + evening.
Automated daily run — signal on each session's open, execute at its close.
Designed for cron/systemd: run once after market close each trading day.
Downloads open + close prices for today, generates signals, executes, records.
Processes every completed session since the last recorded one, so missed
days (monitor downtime, late data) are caught up in order. A session is
only processed after the exchange has closed.
Usage with cron (US market, run at 5pm ET weekdays):
0 17 * * 1-5 cd /path/to/quant && uv run python trader.py auto --market us
For CN market (run at 4pm CST):
0 16 * * 1-5 cd /path/to/quant && uv run python trader.py auto --market cn
"""
import time
state = load_state(args.market, args.strategy)
state = _process_sessions(args, state, refresh=getattr(args, "refresh", True))
if state is not None:
save_state(state, args.market, args.strategy)
pnl = state["daily_equity"][state["last_evening"]] - state["initial_capital"]
print(f"[auto] P&L since inception: {pnl:>+,.2f} "
f"({pnl / state['initial_capital']:+.2%}) | state: "
f"{_state_path(args.market, args.strategy)}")
market = args.market
strategy_name = args.strategy
tickers, benchmark = strategy_universe(market, strategy_name)
data_market = strategy_data_market(market, strategy_name)
all_tickers = sorted(set(tickers + [benchmark]))
# Load or init state
state = load_state(market, strategy_name)
if not state:
state = init_state(market, strategy_name, args.capital)
print(f"[auto] Initialized new portfolio: ${args.capital:,.0f} cash")
def cmd_replay(args):
"""Rebuild live state files by replaying every session from each strategy's start.
# Download data (close + open)
close_data, open_data = data_manager.update(data_market, all_tickers, with_open=True)
tickers = filter_tradable_tickers(close_data, tickers)
today = close_data.index[-1]
today_str = str(today.date())
# Skip if already processed today
if state.get("last_evening") == today_str:
print(f"[auto] {today_str} already processed. Nothing to do.")
return
print(f"\n[auto] {'='*55}")
print(f"[auto] {today_str} | {strategy_name} | {market.upper()}")
print(f"[auto] {'='*55}")
# --- MORNING PHASE: Generate target weights from open prices ---
strategy = STRATEGY_REGISTRY[strategy_name](top_n=max(5, len(tickers) // 10))
target_weights = generate_target_weights(strategy, open_data[tickers], today)
all_held = list(set(list(target_weights.keys()) + list(state["holdings"].keys())))
close_prices = get_prices_for_date(all_held + [benchmark], today, close_data)
if not close_prices:
print(f"[auto] No close prices for {today_str}. Market likely closed.")
return
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
print(f"[auto] Pre-trade value: ${pre_value:,.2f}")
print(f"[auto] Target positions: {len(target_weights)}")
# --- EVENING PHASE: Execute trades at close prices ---
trades = compute_trades(
state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares
Uses exactly the per-session logic of ``auto``. Existing state files are
replaced; their original start date is kept unless ``--start`` is given.
"""
import copy
names = args.strategy or sorted(
os.path.basename(p)[len(f"trader_{args.market}_"):-len(".json")]
for p in __import__("glob").glob(os.path.join("data", f"trader_{args.market}_*.json"))
)
live_names = [n for n in names if not n.startswith("sim_") and n in STRATEGY_REGISTRY
and n not in LIVE_EXCLUDED_STRATEGIES and strategy_supports_market(n, args.market)]
# Fall back to per-market fee when the user didn't explicitly override
fixed_fee = args.fixed_fee if args.fixed_fee > 0 else MARKET_FEES.get(market, 0.0)
tier = MARKET_FEE_TIERED.get(market, {})
execute_trades(state, trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=fixed_fee,
fee_base=tier.get("fee_base", 0.0),
fee_per_share=tier.get("fee_per_share", 0.0),
trade_date=today_str, integer_shares=args.integer_shares)
# Refresh each price panel once for the union of tickers, then replay offline.
refresh_panels(args.market, live_names, label="replay")
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][today_str] = round(post_value, 2)
# Record daily snapshot
eq_vals = list(state["daily_equity"].values())
prev_eq = eq_vals[-2] if len(eq_vals) >= 2 else state["initial_capital"]
record_daily_snapshot(state, today_str, close_prices, trades, prev_eq)
state["last_morning"] = today_str
state["last_evening"] = today_str
state["pending_trades"] = None
save_state(state, market, strategy_name)
# Print summary
pnl = post_value - state["initial_capital"]
pnl_pct = pnl / state["initial_capital"] * 100
n_trades = len(trades)
commission = sum(t.get("commission", 0) for t in state["trade_log"][-n_trades:]) if n_trades else 0
print(f"[auto] Trades: {n_trades} | Commission: ${commission:,.2f}")
print(f"[auto] Post-trade value: ${post_value:,.2f} | Cash: ${state['cash']:,.2f}")
print(f"[auto] P&L: ${pnl:>+,.2f} ({pnl_pct:>+.2f}%)")
print(f"[auto] Holdings: {len(state['holdings'])} positions")
if trades:
for t in trades:
p = close_prices.get(t["ticker"], t["price"])
print(f"[auto] {t['direction']:<4} {t['ticker']:<8} {t['shares_delta']:>+10.2f} @ {p:.2f}")
# Benchmark
bench_eq = state["daily_equity"]
dates = sorted(bench_eq.keys())
if len(dates) >= 2:
prev_val = bench_eq[dates[-2]]
daily_ret = (post_value / prev_val - 1) * 100
print(f"[auto] Daily return: {daily_ret:>+.2f}%")
print(f"[auto] State saved: {_state_path(market, strategy_name)}")
print()
for name in names:
if name.startswith("sim_"):
continue
sub = copy.copy(args)
sub.strategy = name
if name not in STRATEGY_REGISTRY or name in LIVE_EXCLUDED_STRATEGIES \
or not strategy_supports_market(name, args.market):
print(f"[replay] {args.market.upper()}:{name} is not a live strategy here; "
f"removing its state file")
if os.path.exists(_state_path(args.market, name)):
os.remove(_state_path(args.market, name))
continue
old = load_state(args.market, name)
start = pd.Timestamp(args.start) if args.start else (
pd.Timestamp(min(old["daily_equity"])) if old.get("daily_equity") else None
)
if start is None:
print(f"[replay] {name}: no start date; skipping")
continue
state = _process_sessions(sub, None, start=start, label="replay", refresh=False)
if state is not None:
save_state(state, args.market, name)
# ---------------------------------------------------------------------------
@@ -1617,7 +1881,8 @@ def main():
choices=list(STRATEGY_REGISTRY.keys()),
default=DEFAULT_MONITOR_STRATEGIES,
help="Strategies to run (default: ALL)")
p_monitor.add_argument("--capital", type=float, default=10_000)
p_monitor.add_argument("--capital", type=float, default=None,
help="Capital for new portfolios (default: per-market MONITOR_CAPITAL)")
p_monitor.add_argument("--tx-cost", type=float, default=0.001,
help="Proportional transaction cost (default: 0.001 = 10bps)")
p_monitor.add_argument("--fixed-fee", type=float, default=2.0,
@@ -1625,6 +1890,21 @@ def main():
p_monitor.add_argument("--integer-shares", action="store_true", default=True,
help="Only trade whole shares (default: True)")
# Replay: rebuild live state files from each strategy's start date
p_replay = sub.add_parser("replay",
help="Rebuild live state files by replaying all sessions (replaces state)")
p_replay.add_argument("--market", choices=list(UNIVERSES.keys()), default="us")
p_replay.add_argument("--strategy", nargs="+", default=None,
help="Strategies to replay (default: every existing state file)")
p_replay.add_argument("--start", default=None,
help="Start date (default: each state file's first recorded day)")
p_replay.add_argument("--capital", type=float, default=None,
help="Initial capital (default: per-market MONITOR_CAPITAL)")
p_replay.add_argument("--tx-cost", type=float, default=0.001)
p_replay.add_argument("--fixed-fee", type=float, default=0.0,
help="Per-trade fee floor (default: per-market MARKET_FEES)")
p_replay.add_argument("--integer-shares", action=argparse.BooleanOptionalAction, default=True)
# Status (strategy is a free-form string to allow sim_ prefixed names)
p_status = sub.add_parser("status", help="Show current portfolio")
p_status.add_argument("--market", choices=UNIVERSES.keys(), default="us")
@@ -1666,6 +1946,8 @@ def main():
cmd_auto(args)
elif args.command == "monitor":
cmd_monitor(args)
elif args.command == "replay":
cmd_replay(args)
elif args.command == "status":
cmd_status(args)
elif args.command == "simulate":

View File

@@ -129,7 +129,9 @@ UNIVERSES = {
},
"cn": {
"fetch": get_csi300,
"benchmark": "000300.SS",
"benchmark_label": "CSI 300 (Benchmark)",
# Yahoo's 000300.SS index series is truncated (and at times a single
# row); the CSI 300 ETF has a continuous history back to 2012.
"benchmark": "510300.SS",
"benchmark_label": "CSI 300 ETF 510300 (Benchmark)",
},
}

View File

@@ -38,7 +38,13 @@ def _fetch_sp500_tables() -> tuple[pd.DataFrame, pd.DataFrame]:
html = resp.read().decode("utf-8")
tables = pd.read_html(io.StringIO(html))
current = tables[0]
changes = tables[1]
change_tables = [
t for t in tables[1:]
if any("Added" in "_".join(map(str, c if isinstance(c, tuple) else (c,))) for c in t.columns)
]
if not change_tables:
raise ValueError("S&P 500 change log table not found on the Wikipedia page")
changes = change_tables[0]
changes.columns = [
"_".join(c).strip() if isinstance(c, tuple) else c
for c in changes.columns
@@ -161,7 +167,16 @@ def load_sp500_history(force_refresh: bool = False) -> dict[str, list[list[str |
except Exception:
pass
print("--- Rebuilding S&P 500 membership history from Wikipedia ---")
intervals = build_sp500_history()
try:
intervals = build_sp500_history()
except Exception as exc: # noqa: BLE001 — page layout changes must not break backtests
if not os.path.exists(path):
raise
with open(path) as f:
cached = json.load(f)
print(f"--- WARNING: rebuild failed ({exc}); using membership history "
f"cached on {cached.get('date')} ---")
return cached["intervals"]
os.makedirs(CACHE_DIR, exist_ok=True)
with open(path, "w") as f:
json.dump({"date": str(date.today()), "intervals": intervals}, f)

2
uv.lock generated
View File

@@ -761,7 +761,7 @@ wheels = [
]
[[package]]
name = "quant"
name = "quant-trading"
version = "0.1.0"
source = { virtual = "." }
dependencies = [

7
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
data/
.env
.DS_Store
*.log
.qoder/

68
web/README.md Normal file
View File

@@ -0,0 +1,68 @@
# quant-trading web
Web UI and API for the quant-trading repository: strategy backtests and live books (via `../bridge.py`), plus a personal watchlist agent.
统一个人股票监控 MVP,支持 A股、港股、美股自选股,按日生成监控报告。数据接口固定使用 IPADS Responses API,密钥从仓库根目录 `.env` 的 `IPADS_API_KEY` 读取。
## Run
```bash
npm install
npm run dev
```
Frontend: http://127.0.0.1:5173
Backend: http://127.0.0.1:8787
LAN access:
```bash
HOST=0.0.0.0 CLIENT_PORT=5174 PORT=8787 npm run dev
```
Open `http://gahow-pc.ipads-lab.se.sjtu.edu.cn:5174` from another device on the same network.
## IPADS API
配置文件:
```env
IPADS_API_KEY=your_api_key
```
固定服务端配置:
```text
base_url = "http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1"
wire_api = "responses"
env_key = "IPADS_API_KEY"
```
The backend calls:
```text
POST http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1/responses
Authorization: Bearer ${IPADS_API_KEY}
```
The response output text must be JSON matching the stock snapshot schema used by the agent. If `IPADS_API_KEY` is blank, the app uses deterministic demo data so the dashboard remains usable.
## Features
- Add/remove watchlist symbols such as `600519`, `000001.SZ`, `00700.HK`, `AAPL`, `MSFT.US`.
- Normalize symbols into A股、港股、美股 markets.
- Generate a daily report with earnings timing, peer stocks, valuation discount/premium, related news, and buy-watch/hold/avoid recommendations.
- Refreshes the cached daily report every 24 hours while the backend process is running.
- View each watchlist stock with locally cached D/W/M/Y K-line chart data, zoom controls, MA5/MA20/MA60, RSI14, MACD, volume, and cached financial statements. US K-line and quote/research fields use Nasdaq as the fixed data source and target at least 5 years of verified daily OHLCV history when available.
- Cache related market context such as sector, liquidity, volatility, drawdown, relative strength, capital flow, sentiment, and risk flags.
- Run an agent-backed quantitative analysis across technical, valuation, growth, quality, liquidity, sentiment, and risk factors to classify each stock as buy-watch/hold/avoid.
- Add tags to every watchlist stock and filter the watchlist by tag.
- In configured provider mode, prompts require verified data and do not permit approximate or fabricated values; missing data is surfaced instead of being filled with synthetic values.
- K-line cache updates incrementally: once 5 years of daily bars exist locally, refreshes request only the latest missing window and merge by date.
## Local cache
Web state lives in the repo-level `data/web/` (see `src/server/paths.ts`): `watchlist.json`, per-stock snapshot/K-line/financial data under `cache/`, and backtest results under `strategy_cache/`. The strategy page reads `data/precomputed/backtest_results.json`, written by `precompute_backtest.py` at the repo root.
Production runs as systemd services; see "Server Deployment" in the root `CLAUDE.md`.

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Personal Stock Monitor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

3491
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
web/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "quant-trading-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
"dev:server": "tsx watch src/server/index.ts",
"dev:client": "vite",
"test": "vitest run",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.0",
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^5.1.0",
"lightweight-charts": "^5.2.0",
"lucide-react": "^1.14.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"concurrently": "^9.2.1",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"vite": "^7.1.3",
"vitest": "^3.2.4"
}
}

119
web/src/agent/indicators.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { IndicatorPoint, PriceCandle } from "../shared/types";
export function calculateIndicators(candles: PriceCandle[]): IndicatorPoint[] {
const closes = candles.map((candle) => candle.close);
const ema12 = ema(closes, 12);
const ema26 = ema(closes, 26);
const macdLine = closes.map((_, index) => valueOrUndefined(ema12[index], ema26[index], (fast, slow) => fast - slow));
const signalLine = ema(macdLine, 9);
const rsi = rsi14(closes);
return candles.map((candle, index) => {
const macd = macdLine[index];
const macdSignal = signalLine[index];
return {
date: candle.date,
ma5: movingAverage(closes, index, 5),
ma20: movingAverage(closes, index, 20),
ma60: movingAverage(closes, index, 60),
ma120: movingAverage(closes, index, 120),
ma200: movingAverage(closes, index, 200),
rsi14: rsi[index],
macd,
macdSignal,
macdHistogram: valueOrUndefined(macd, macdSignal, (line, signal) => line - signal)
};
});
}
function movingAverage(values: number[], index: number, window: number): number | undefined {
if (index + 1 < window) {
return undefined;
}
const slice = values.slice(index + 1 - window, index + 1);
return round(slice.reduce((sum, value) => sum + value, 0) / window, 4);
}
function ema(values: Array<number | undefined>, window: number): Array<number | undefined> {
const result: Array<number | undefined> = [];
const multiplier = 2 / (window + 1);
let previous: number | undefined;
let seedValues: number[] = [];
for (const value of values) {
if (value === undefined || !Number.isFinite(value)) {
result.push(undefined);
continue;
}
if (previous === undefined) {
seedValues.push(value);
if (seedValues.length < window) {
result.push(undefined);
continue;
}
previous = seedValues.reduce((sum, item) => sum + item, 0) / window;
result.push(round(previous, 4));
continue;
}
previous = value * multiplier + previous * (1 - multiplier);
result.push(round(previous, 4));
}
return result;
}
function rsi14(closes: number[]): Array<number | undefined> {
const result: Array<number | undefined> = [undefined];
let gains = 0;
let losses = 0;
for (let index = 1; index < closes.length; index += 1) {
const change = closes[index] - closes[index - 1];
const gain = Math.max(0, change);
const loss = Math.max(0, -change);
if (index <= 14) {
gains += gain;
losses += loss;
if (index === 14) {
gains /= 14;
losses /= 14;
result.push(rsiValue(gains, losses));
} else {
result.push(undefined);
}
continue;
}
gains = (gains * 13 + gain) / 14;
losses = (losses * 13 + loss) / 14;
result.push(rsiValue(gains, losses));
}
return result;
}
function rsiValue(gain: number, loss: number): number {
if (loss === 0) {
return 100;
}
return round(100 - 100 / (1 + gain / loss), 4);
}
function valueOrUndefined(
left: number | undefined,
right: number | undefined,
calculate: (left: number, right: number) => number
): number | undefined {
if (left === undefined || right === undefined) {
return undefined;
}
return round(calculate(left, right), 4);
}
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}

1240
web/src/agent/provider.ts Normal file

File diff suppressed because it is too large Load Diff

144
web/src/agent/report.ts Normal file
View File

@@ -0,0 +1,144 @@
import type {
DailyReport,
DailyReportItem,
EarningsSignal,
Market,
MarketDataProvider,
Recommendation,
StockSnapshot,
ValuationSignal,
WatchStock
} from "../shared/types";
const DAY_MS = 24 * 60 * 60 * 1000;
export async function generateDailyReport(
watchlist: WatchStock[],
provider: MarketDataProvider,
now = new Date()
): Promise<DailyReport> {
const items = await Promise.all(
watchlist.map(async (stock) => buildReportItem(stock, await provider.snapshot(stock), now))
);
return {
generatedAt: now.toISOString(),
summary: buildSummary(items),
items: items.sort((left, right) => rankItem(right) - rankItem(left))
};
}
function buildReportItem(stock: WatchStock, snapshot: StockSnapshot, now: Date): DailyReportItem {
const earnings = analyzeEarnings(snapshot, now);
const valuation = analyzeValuation(snapshot);
const recommendation = recommend(snapshot, valuation, earnings);
return {
stock,
snapshot,
earnings,
valuation,
recommendation,
news: snapshot.news
};
}
function analyzeEarnings(snapshot: StockSnapshot, now: Date): EarningsSignal {
if (!snapshot.nextEarningsDate) {
return { status: "none" };
}
const date = new Date(`${snapshot.nextEarningsDate}T00:00:00.000Z`);
const start = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const daysUntil = Math.ceil((date.getTime() - start) / DAY_MS);
return {
status: daysUntil >= 0 && daysUntil <= 14 ? "soon" : "scheduled",
nextDate: snapshot.nextEarningsDate,
daysUntil
};
}
function analyzeValuation(snapshot: StockSnapshot): ValuationSignal {
const stockPe = snapshot.pe;
const peerPes = snapshot.peers.map((peer) => peer.pe).filter((pe) => Number.isFinite(pe) && pe > 0);
if (!Number.isFinite(stockPe) || stockPe === undefined || stockPe <= 0 || peerPes.length === 0) {
return { label: "unknown" };
}
const peerAveragePe = round(peerPes.reduce((sum, pe) => sum + pe, 0) / peerPes.length, 2);
const spreadPct = round((stockPe - peerAveragePe) / peerAveragePe, 4);
const label = spreadPct <= -0.15 ? "discount" : spreadPct >= 0.15 ? "premium" : "fair";
return {
label,
stockPe,
peerAveragePe,
spreadPct
};
}
function recommend(snapshot: StockSnapshot, valuation: ValuationSignal, earnings: EarningsSignal): Recommendation {
const revenueGrowth = snapshot.revenueGrowth ?? 0;
const profitGrowth = snapshot.profitGrowth ?? 0;
const reasons: string[] = [];
if (valuation.label === "discount") {
reasons.push("估值低于可比股票均值");
}
if (valuation.label === "premium") {
reasons.push("估值高于可比股票均值");
}
if (revenueGrowth > 0) {
reasons.push("收入仍在增长");
}
if (profitGrowth > 0) {
reasons.push("利润仍在增长");
}
if (earnings.status === "soon") {
reasons.push("未来 14 天内有财报事件");
}
if (valuation.label === "discount" && revenueGrowth > 0 && profitGrowth >= 0) {
return { action: "buy_watch", confidence: 0.72, reasons };
}
if (valuation.label === "premium" && revenueGrowth < 0 && profitGrowth < 0) {
return { action: "avoid", confidence: 0.68, reasons };
}
return {
action: "hold",
confidence: earnings.status === "soon" ? 0.58 : 0.52,
reasons: reasons.length ? reasons : ["等待更明确的基本面或估值信号"]
};
}
function buildSummary(items: DailyReportItem[]): string {
const counts = new Map<Market, number>([
["CN", 0],
["HK", 0],
["US", 0]
]);
for (const item of items) {
counts.set(item.stock.market, (counts.get(item.stock.market) ?? 0) + 1);
}
const soon = items.filter((item) => item.earnings.status === "soon").length;
const discounts = items.filter((item) => item.valuation.label === "discount").length;
return `CN: ${counts.get("CN")}; HK: ${counts.get("HK")}; US: ${counts.get("US")}; earnings soon: ${soon}; valuation discounts: ${discounts}`;
}
function rankItem(item: DailyReportItem): number {
return (
(item.earnings.status === "soon" ? 4 : 0) +
(item.valuation.label === "discount" ? 3 : 0) +
(item.recommendation.action === "buy_watch" ? 2 : 0) +
item.news.length
);
}
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}

View File

@@ -0,0 +1,380 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { calculateIndicators } from "./indicators";
import type {
ChartPeriod,
FinancialStatement,
MarketDataProvider,
PriceCandle,
QuantAnalysis,
RelatedStockData,
StockChartData,
StockFundamentalsData,
StockQuantAnalysis,
StockRelatedData,
StockSnapshot,
WatchStock
} from "../shared/types";
interface CachedEntry<T> {
cachedAt: string;
data: T;
}
interface StockCacheFile {
snapshot?: CachedEntry<StockSnapshot>;
candles?: CachedEntry<PriceCandle[]>;
candlesWindowYears?: number;
candlesProviderVersion?: number;
fundamentals?: CachedEntry<FinancialStatement[]>;
relatedData?: CachedEntry<RelatedStockData>;
quantAnalysis?: CachedEntry<QuantAnalysis> & { version?: number };
}
const SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000;
const CANDLES_TTL_MS = 24 * 60 * 60 * 1000;
const FUNDAMENTALS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const RELATED_TTL_MS = 24 * 60 * 60 * 1000;
const ANALYSIS_VERSION = 4;
const CANDLES_WINDOW_YEARS = 10;
const CANDLES_PROVIDER_VERSION = 2;
export class StockDataCache {
constructor(
private readonly cacheDir: string,
private readonly provider: MarketDataProvider,
private readonly now: () => Date = () => new Date()
) {}
async snapshot(stock: WatchStock, force = false): Promise<StockSnapshot> {
const cache = await this.read(stock);
if (!force && cache.snapshot && !isExpired(cache.snapshot.cachedAt, SNAPSHOT_TTL_MS, this.now())) {
return cache.snapshot.data;
}
const next = await this.provider.snapshot(stock);
await this.write(stock, { ...cache, snapshot: { cachedAt: this.now().toISOString(), data: next } });
return next;
}
async chart(stock: WatchStock, period: ChartPeriod = "D", force = false): Promise<StockChartData> {
const cache = await this.read(stock);
const existing = cache.candles;
let entry: CachedEntry<PriceCandle[]>;
try {
if (!existing || force || cache.candlesWindowYears !== CANDLES_WINDOW_YEARS || cache.candlesProviderVersion !== CANDLES_PROVIDER_VERSION) {
entry = { cachedAt: this.now().toISOString(), data: await this.provider.candles(stock) };
} else if (isExpired(existing.cachedAt, CANDLES_TTL_MS, this.now())) {
entry = existing;
void this.refreshCandles(stock, existing);
} else {
entry = existing;
}
} catch (error) {
if (existing) {
entry = existing;
} else {
throw error;
}
}
if (entry !== cache.candles) {
await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION });
}
const candles = aggregateCandles(entry.data, period);
return {
stock,
period,
candles,
indicators: calculateIndicators(candles),
cachedAt: entry.cachedAt
};
}
private async refreshCandles(stock: WatchStock, existing: CachedEntry<PriceCandle[]>): Promise<void> {
try {
const cache = await this.read(stock);
if (cache.candles?.cachedAt !== existing.cachedAt) {
return;
}
const startDate = previousCalendarDate(existing.data[existing.data.length - 1]?.date);
const incremental = startDate ? await this.provider.candles(stock, startDate) : await this.provider.candles(stock);
const entry = {
cachedAt: this.now().toISOString(),
data: incremental.length ? mergeCandles(existing.data, incremental) : existing.data
};
await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION });
} catch {
// Keep stale cache when the upstream market data source is unavailable.
}
}
async refreshChart(stock: WatchStock, period: ChartPeriod = "D"): Promise<StockChartData> {
const cache = await this.read(stock);
const existing = cache.candles;
let entry: CachedEntry<PriceCandle[]>;
if (existing && cache.candlesWindowYears === CANDLES_WINDOW_YEARS && cache.candlesProviderVersion === CANDLES_PROVIDER_VERSION) {
const startDate = previousCalendarDate(existing.data[existing.data.length - 1]?.date);
const incremental = startDate ? await this.provider.candles(stock, startDate) : await this.provider.candles(stock);
entry = {
cachedAt: this.now().toISOString(),
data: incremental.length ? mergeCandles(existing.data, incremental) : existing.data
};
} else {
entry = { cachedAt: this.now().toISOString(), data: await this.provider.candles(stock) };
}
await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION });
const candles = aggregateCandles(entry.data, period);
return {
stock,
period,
candles,
indicators: calculateIndicators(candles),
cachedAt: entry.cachedAt
};
}
async fundamentals(stock: WatchStock, force = false): Promise<StockFundamentalsData> {
const cache = await this.read(stock);
let entry: CachedEntry<FinancialStatement[]>;
try {
entry =
!force && cache.fundamentals
? cache.fundamentals
: { cachedAt: this.now().toISOString(), data: await this.provider.fundamentals(stock) };
} catch {
entry = cache.fundamentals ?? { cachedAt: this.now().toISOString(), data: [] };
}
if (entry !== cache.fundamentals) {
await this.write(stock, { ...cache, fundamentals: entry });
}
return {
stock,
statements: entry.data,
cachedAt: entry.cachedAt
};
}
async relatedData(stock: WatchStock, force = false): Promise<StockRelatedData> {
const cache = await this.read(stock);
let entry: CachedEntry<RelatedStockData>;
try {
entry =
!force && cache.relatedData && hasResearchSchema(cache.relatedData.data)
? cache.relatedData
: { cachedAt: this.now().toISOString(), data: await this.provider.relatedData(stock) };
} catch {
entry = cache.relatedData ?? {
cachedAt: this.now().toISOString(),
data: {
riskFlags: ["相关市场数据源暂不可用。"],
sources: []
}
};
}
if (entry !== cache.relatedData) {
await this.write(stock, { ...cache, relatedData: entry });
}
return {
stock,
data: entry.data,
cachedAt: entry.cachedAt
};
}
async quantAnalysis(stock: WatchStock, force = false): Promise<StockQuantAnalysis> {
const cache = await this.read(stock);
const hasTodayAnalysis =
cache.quantAnalysis?.version === ANALYSIS_VERSION && isSameShanghaiDate(cache.quantAnalysis.cachedAt, this.now());
if (!force && cache.quantAnalysis?.version === ANALYSIS_VERSION) {
return {
stock,
analysis: cache.quantAnalysis.data,
generatedAt: cache.quantAnalysis.cachedAt,
cachedAt: cache.quantAnalysis.cachedAt
};
}
const refreshSourceData = force || !hasTodayAnalysis;
const snapshot = await this.snapshot(stock, refreshSourceData);
const chart = await this.chart(stock, "D", refreshSourceData);
const fundamentals = await this.fundamentals(stock, refreshSourceData).catch(() => ({ stock, statements: [], cachedAt: this.now().toISOString() }));
const related = await this.relatedData(stock, refreshSourceData).catch(() => ({
stock,
data: { riskFlags: ["相关市场数据源暂不可用。"], sources: [] },
cachedAt: this.now().toISOString()
}));
const generatedAt = this.now().toISOString();
const analysis = await this.provider.quantAnalysis({
stock,
snapshot,
candles: chart.candles,
indicators: chart.indicators,
fundamentals: fundamentals.statements,
relatedData: related.data
});
const entry = { cachedAt: generatedAt, data: analysis, version: ANALYSIS_VERSION };
await this.write(stock, { ...(await this.read(stock)), quantAnalysis: entry });
return {
stock,
analysis,
generatedAt,
cachedAt: entry.cachedAt
};
}
async analysisNeedsRefresh(stock: WatchStock): Promise<boolean> {
const cache = await this.read(stock);
return cache.quantAnalysis?.version !== ANALYSIS_VERSION || !isSameShanghaiDate(cache.quantAnalysis.cachedAt, this.now());
}
async hasAnalysis(stock: WatchStock): Promise<boolean> {
const cache = await this.read(stock);
return cache.quantAnalysis?.version === ANALYSIS_VERSION;
}
placeholderAnalysis(stock: WatchStock): StockQuantAnalysis {
const generatedAt = this.now().toISOString();
return {
stock,
generatedAt,
cachedAt: generatedAt,
analysis: {
action: "hold",
score: 50,
confidence: 0.1,
horizon: "后台生成中",
summary: "本地暂无 Agent 分析缓存,已在后台生成最新量化分析。当前先展示缓存数据,避免切换自选股时阻塞界面。",
factors: [
{ name: "缓存状态", score: 50, weight: 1, evidence: "等待后台 Agent 分析写入本地缓存。" },
{ name: "技术面", score: 50, weight: 0, evidence: "K线和指标可先独立查看。" },
{ name: "风险控制", score: 50, weight: 0, evidence: "完整交易计划将在后台分析完成后更新。" }
],
tradePlan: {
direction: "wait",
rationale: "后台分析完成前不生成买入、卖出或止损价。"
},
marketContext: ["Agent 分析正在后台生成。"],
riskNotes: ["该占位结果只表示后台任务状态,不代表实际投资判断。"]
}
};
}
private async read(stock: WatchStock): Promise<StockCacheFile> {
try {
return JSON.parse(await readFile(this.pathFor(stock), "utf8")) as StockCacheFile;
} catch (error) {
if (isMissingFile(error)) {
return {};
}
throw error;
}
}
private async write(stock: WatchStock, cache: StockCacheFile): Promise<void> {
await mkdir(this.cacheDir, { recursive: true });
await writeFile(this.pathFor(stock), `${JSON.stringify(cache, null, 2)}\n`, "utf8");
}
private pathFor(stock: WatchStock): string {
return join(this.cacheDir, `${stock.display.replace(/[^A-Z0-9]/g, "_")}.json`);
}
}
function isExpired(cachedAt: string, ttlMs: number, now: Date): boolean {
const cachedTime = new Date(cachedAt).getTime();
return !Number.isFinite(cachedTime) || now.getTime() - cachedTime > ttlMs;
}
function isMissingFile(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
function mergeCandles(existing: PriceCandle[], incoming: PriceCandle[]): PriceCandle[] {
const byDate = new Map<string, PriceCandle>();
for (const candle of existing) {
byDate.set(candle.date, candle);
}
for (const candle of incoming) {
byDate.set(candle.date, candle);
}
return Array.from(byDate.values()).sort((left, right) => left.date.localeCompare(right.date));
}
function previousCalendarDate(date: string | undefined): string | undefined {
if (!date) {
return undefined;
}
const value = new Date(`${date}T00:00:00.000Z`);
if (!Number.isFinite(value.getTime())) {
return undefined;
}
value.setUTCDate(value.getUTCDate() - 7);
return value.toISOString().slice(0, 10);
}
function hasResearchSchema(data: RelatedStockData): boolean {
return Array.isArray(data.sources) && data.sources.some((source) => source.name.startsWith("Nasdaq") || source.name.startsWith("Sina"));
}
function isSameShanghaiDate(cachedAt: string, now: Date): boolean {
const cachedTime = new Date(cachedAt);
if (!Number.isFinite(cachedTime.getTime())) {
return false;
}
return shanghaiDate(cachedTime) === shanghaiDate(now);
}
function shanghaiDate(date: Date): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit"
}).format(date);
}
function aggregateCandles(candles: PriceCandle[], period: ChartPeriod): PriceCandle[] {
if (period === "D") {
return candles;
}
const grouped = new Map<string, PriceCandle>();
for (const candle of candles) {
const key = periodKey(candle.date, period);
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, { ...candle });
continue;
}
existing.high = Math.max(existing.high, candle.high);
existing.low = Math.min(existing.low, candle.low);
existing.close = candle.close;
existing.volume += candle.volume;
existing.date = candle.date;
}
return Array.from(grouped.values());
}
function periodKey(date: string, period: ChartPeriod): string {
if (period === "M") {
return date.slice(0, 7);
}
if (period === "Y") {
return date.slice(0, 4);
}
return isoWeekKey(date);
}
function isoWeekKey(date: string): string {
const value = new Date(`${date}T00:00:00.000Z`);
const day = value.getUTCDay() || 7;
value.setUTCDate(value.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(value.getUTCFullYear(), 0, 1));
const week = Math.ceil(((value.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
return `${value.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}

52
web/src/agent/symbols.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { Market, NormalizedSymbol } from "../shared/types";
const CN_SUFFIXES = new Set(["SS", "SH", "SZ"]);
export function normalizeSymbol(input: string): NormalizedSymbol {
const raw = input.trim().toUpperCase();
if (!raw) {
throw new Error("Stock symbol is required");
}
const suffixMatch = raw.match(/^([A-Z0-9]+)\.([A-Z]{2})$/);
if (suffixMatch) {
const [, base, suffix] = suffixMatch;
if (CN_SUFFIXES.has(suffix)) {
const exchange = inferChinaExchange(base);
return {
symbol: base,
market: "CN",
display: `${base}.${exchange}`
};
}
if (suffix === "HK" && /^\d{4,5}$/.test(base)) {
return { symbol: base.padStart(5, "0"), market: "HK", display: `${base.padStart(5, "0")}.HK` };
}
if (suffix === "US" && /^[A-Z]{1,6}$/.test(base)) {
return { symbol: base, market: "US", display: base };
}
}
if (/^\d{6}$/.test(raw)) {
return { symbol: raw, market: "CN", display: `${raw}.${inferChinaExchange(raw)}` };
}
if (/^\d{4,5}$/.test(raw)) {
const symbol = raw.padStart(5, "0");
return { symbol, market: "HK", display: `${symbol}.HK` };
}
if (/^[A-Z]{1,6}$/.test(raw)) {
return { symbol: raw, market: "US", display: raw };
}
throw new Error("Unsupported stock symbol");
}
function inferChinaExchange(symbol: string): "SS" | "SZ" {
return symbol.startsWith("5") || symbol.startsWith("6") || symbol.startsWith("9") ? "SS" : "SZ";
}
export function marketLabel(market: Market): string {
return market === "CN" ? "A股" : market === "HK" ? "港股" : "美股";
}

View File

@@ -0,0 +1,96 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { normalizeSymbol } from "./symbols";
import type { WatchStock } from "../shared/types";
export class WatchlistStore {
constructor(private readonly path: string) {}
async list(): Promise<WatchStock[]> {
return this.read();
}
async add(symbolInput: string, name?: string, tags: string[] = []): Promise<WatchStock> {
const normalized = normalizeSymbol(symbolInput);
const stocks = await this.read();
const existing = stocks.find((stock) => stock.display === normalized.display);
if (existing) {
return existing;
}
const stock: WatchStock = {
...normalized,
id: normalized.display,
name: name?.trim() || undefined,
tags: normalizeTags(tags),
addedAt: new Date().toISOString()
};
await this.write([...stocks, stock]);
return stock;
}
async updateTags(symbolInput: string, tags: string[]): Promise<WatchStock> {
const normalized = normalizeSymbol(symbolInput);
const stocks = await this.read();
const stock = stocks.find((item) => item.display === normalized.display);
if (!stock) {
throw new Error("Stock is not in watchlist");
}
const updated = { ...stock, tags: normalizeTags(tags) };
await this.write(stocks.map((item) => (item.display === normalized.display ? updated : item)));
return updated;
}
async remove(symbolInput: string): Promise<void> {
const normalized = normalizeSymbol(symbolInput);
const stocks = await this.read();
await this.write(stocks.filter((stock) => stock.display !== normalized.display));
}
private async read(): Promise<WatchStock[]> {
try {
const content = await readFile(this.path, "utf8");
const parsed = JSON.parse(content) as WatchStock[];
if (!Array.isArray(parsed)) {
return [];
}
const byDisplay = new Map<string, WatchStock>();
for (const stock of parsed) {
const normalized = normalizeSymbol(stock.display ?? stock.symbol);
byDisplay.set(normalized.display, {
...stock,
...normalized,
id: normalized.display,
tags: normalizeTags(stock.tags ?? [])
});
}
return Array.from(byDisplay.values());
} catch (error) {
if (isMissingFile(error)) {
return [];
}
throw error;
}
}
private async write(stocks: WatchStock[]): Promise<void> {
await mkdir(dirname(this.path), { recursive: true });
await writeFile(this.path, `${JSON.stringify(stocks, null, 2)}\n`, "utf8");
}
}
function isMissingFile(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
function normalizeTags(tags: string[]): string[] {
return Array.from(
new Set(
tags
.map((tag) => tag.trim())
.filter(Boolean)
.map((tag) => tag.slice(0, 24))
)
).slice(0, 8);
}

686
web/src/client/LivePage.tsx Normal file
View File

@@ -0,0 +1,686 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
ColorType,
CrosshairMode,
LineSeries,
createChart,
type IChartApi,
type LineData,
type Time,
} from "lightweight-charts";
import { ArrowDownRight, ArrowUpRight, RefreshCw, Wallet } from "lucide-react";
import {
calendarDaysSince,
currencyForMarket,
formatMoney,
formatNumber,
formatPct,
type EquityPoint,
type LiveStrategyState,
type StrategyCurrency,
type StrategyMarket,
type TradeLogEntry,
} from "../shared/strategyTypes";
// A strategy whose state was last updated more than this many calendar days
// ago is flagged (covers a normal weekend plus a holiday).
const LIVE_STALE_DAYS = 4;
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!response.ok) {
const body = (await response.json().catch(() => undefined)) as { error?: string } | undefined;
throw new Error(body?.error ?? `Request failed: ${response.status}`);
}
return (await response.json()) as T;
}
type SortKey = "currentValue" | "unrealizedPnlPct" | "name" | "lastUpdated";
export function LivePage() {
const [market, setMarket] = useState<StrategyMarket>("us");
const [strategies, setStrategies] = useState<LiveStrategyState[]>([]);
const [selectedName, setSelectedName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("unrealizedPnlPct");
const [filterText, setFilterText] = useState("");
const refresh = (mk: StrategyMarket = market) => {
setLoading(true);
setError("");
api<LiveStrategyState[]>(`/api/strategies/live?market=${mk}`)
.then((res) => {
setStrategies(res);
if (res.length > 0) {
setSelectedName((prev) =>
prev && res.some((s) => s.name === prev) ? prev : res[0].name,
);
} else {
setSelectedName(null);
}
})
.catch((err) => setError(err instanceof Error ? err.message : "加载失败"))
.finally(() => setLoading(false));
};
useEffect(() => {
refresh(market);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [market]);
const sorted = useMemo(() => {
const list = [...strategies];
const ft = filterText.trim().toLowerCase();
const filtered = ft
? list.filter(
(s) =>
s.name.toLowerCase().includes(ft) ||
s.label.toLowerCase().includes(ft),
)
: list;
filtered.sort((a, b) => {
switch (sortKey) {
case "currentValue":
return (b.currentValue ?? 0) - (a.currentValue ?? 0);
case "unrealizedPnlPct":
return (b.unrealizedPnlPct ?? 0) - (a.unrealizedPnlPct ?? 0);
case "name":
return a.label.localeCompare(b.label);
case "lastUpdated":
return (b.lastUpdated ?? "").localeCompare(a.lastUpdated ?? "");
}
});
return filtered;
}, [strategies, sortKey, filterText]);
const focused = useMemo(
() => sorted.find((s) => s.name === selectedName) ?? sorted[0],
[sorted, selectedName],
);
return (
<div className="livePage">
{error && <div className="alert">{error}</div>}
<div className="liveTopbar">
<div className="segmented small">
<button className={market === "us" ? "active" : ""} onClick={() => setMarket("us")}>美股</button>
<button className={market === "cn" ? "active" : ""} onClick={() => setMarket("cn")}>A股</button>
</div>
<input
className="ghostInput"
placeholder="筛选策略..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
<label className="ghostSelectLabel">
<span>排序</span>
<select
className="ghostSelect"
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
>
<option value="unrealizedPnlPct">盈亏%</option>
<option value="currentValue">总资产</option>
<option value="name">名称</option>
<option value="lastUpdated">更新时间</option>
</select>
</label>
<button
className={`ghostButton ${loading ? "loading" : ""}`}
onClick={() => refresh()}
disabled={loading}
title="重新读取 live 状态(不会拉取新行情)"
type="button"
>
<RefreshCw size={14} />
{loading ? "加载中" : "刷新"}
</button>
</div>
<div className="liveLayout">
<aside className="panel liveSidebar">
<div className="panelHeader">
<h2>策略列表</h2>
<span>{sorted.length} / {strategies.length}</span>
</div>
{loading && sorted.length === 0 ? (
<p className="hint">加载中...</p>
) : sorted.length === 0 ? (
<p className="hint">暂无在线策略。运行 quant 的 trader.py auto 后再刷新。</p>
) : (
<div className="liveList">
{sorted.map((s) => (
<button
key={s.name}
className={`liveListItem ${focused?.name === s.name ? "selected" : ""}`}
onClick={() => setSelectedName(s.name)}
title={`${s.label} (${s.name})`}
type="button"
>
<div className="liveListTitle">
<strong>{s.label}</strong>
<span className="liveListName">{s.name}</span>
</div>
<div className="liveListBody">
<span className="liveListValue">{money(s.currentValue, currencyOf(s))}</span>
<span
className={`liveListPnl ${s.unrealizedPnl >= 0 ? "positive" : "negative"}`}
>
{s.unrealizedPnl >= 0 ? <ArrowUpRight size={11} /> : <ArrowDownRight size={11} />}
{pct(s.unrealizedPnlPct)}
</span>
</div>
<div className="liveListMeta">
<span>持仓 {Object.keys(s.holdings).length} · 起 {s.startDate ?? "—"}</span>
<span>
{isStale(s) && <span className="staleBadge">滞后</span>}
{s.lastUpdated ?? "-"}
</span>
</div>
</button>
))}
</div>
)}
</aside>
<section className="liveDetail">
{focused ? (
<LiveStrategyDetail key={focused.name} strategy={focused} />
) : (
<div className="panel emptyState">
<Wallet size={40} />
<p>未选择策略</p>
</div>
)}
</section>
</div>
</div>
);
}
function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {
const m = strategy;
const currency = currencyOf(m);
const staleDays = calendarDaysSince(m.lastUpdated);
const bench = benchmarkComparison(m);
return (
<>
<div className="panel liveSummary">
<div className="liveSummaryHead">
<div>
<p className="eyebrow">{m.name}</p>
<h2>{m.label}</h2>
<p className="hint">
{m.market.toUpperCase()} · {currency} · 初始 {money(m.initialCapital, currency)} · 起始 {m.startDate ?? "—"}
{" "}· 更新于 {m.lastUpdated ?? "-"} · 价格截至 {m.priceAsOf ?? "—"}
{isStale(m) && <span className="staleBadge">已 {staleDays} 天未更新</span>}
</p>
</div>
<div className={`liveTotalPnl ${m.unrealizedPnl >= 0 ? "positive" : "negative"}`}>
<strong>{money(m.currentValue, currency)}</strong>
<span>
{m.unrealizedPnl >= 0 ? "+" : ""}
{money(m.unrealizedPnl, currency)} ({pct(m.unrealizedPnlPct)})
</span>
</div>
</div>
<div className="liveSummaryGrid">
<SummaryCell label="现金" value={money(m.cash, currency)} />
<SummaryCell label="持仓市值" value={money(m.marketValue, currency)} />
<SummaryCell label="总资产" value={money(m.currentValue, currency)} />
<SummaryCell label="持仓数" value={`${Object.keys(m.holdings).length}`} />
<SummaryCell label="累计交易" value={`${m.tradeLog.length}`} />
{m.metrics && <SummaryCell label="Sharpe" value={formatNumber(m.metrics.sharpeRatio)} />}
{m.metrics && <SummaryCell label="最大回撤" value={pct(m.metrics.maxDrawdown)} negative />}
{m.metrics && <SummaryCell label="日胜率" value={pct(m.metrics.winRate)} />}
{bench ? (
<>
<SummaryCell label={`同期基准 ${bench.name}`} value={pct(bench.benchmarkReturn)} />
<SummaryCell
label="超额收益 (同期)"
value={pct(bench.excessReturn)}
positive={bench.excessReturn > 0}
negative={bench.excessReturn < 0}
/>
</>
) : (
<SummaryCell label="基准数据暂缺" value="—" />
)}
</div>
</div>
<LiveEquityChart
equityCurve={m.equityCurve}
initialCapital={m.initialCapital}
currency={currency}
benchmark={bench ? m.benchmark : undefined}
/>
<HoldingsPanel
holdings={m.holdingsDetail}
cash={m.cash}
currentValue={m.currentValue}
currency={currency}
/>
<TradeLogPanel log={m.tradeLog} currency={currency} />
</>
);
}
function currencyOf(s: LiveStrategyState): StrategyCurrency {
return s.currency ?? currencyForMarket(s.market);
}
function isStale(s: LiveStrategyState): boolean {
const days = calendarDaysSince(s.lastUpdated);
return days !== null && days > LIVE_STALE_DAYS;
}
// Strategy vs. benchmark over the benchmark's window. The benchmark is rebased
// to initialCapital, so both returns share the same starting capital.
function benchmarkComparison(s: LiveStrategyState) {
const curve = s.benchmark?.equityCurve ?? [];
const first = curve[0]?.value;
const last = curve.at(-1);
if (!last || !first || !s.initialCapital || s.equityCurve.length === 0) return null;
const strategyEnd = s.equityCurve.find((p) => p.date === last.date) ?? s.equityCurve.at(-1)!;
const benchmarkReturn = last.value / first - 1;
const strategyReturn = strategyEnd.value / s.initialCapital - 1;
if (!Number.isFinite(benchmarkReturn) || !Number.isFinite(strategyReturn)) return null;
return {
name: s.benchmark?.name ?? "基准",
benchmarkReturn,
excessReturn: strategyReturn - benchmarkReturn,
};
}
function SummaryCell({
label,
value,
positive,
negative,
}: {
label: string;
value: string;
positive?: boolean;
negative?: boolean;
}) {
const cls = positive ? "positive" : negative ? "negative" : "";
return (
<div className={`summaryCell ${cls}`}>
<span className="summaryValue">{value}</span>
<span className="summaryLabel">{label}</span>
</div>
);
}
function LiveEquityChart({
equityCurve,
initialCapital,
currency,
benchmark,
}: {
equityCurve: EquityPoint[];
initialCapital: number;
currency: StrategyCurrency;
benchmark?: { name: string; equityCurve: EquityPoint[] };
}) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height: 280,
layout: {
background: { type: ColorType.Solid, color: "#0d1219" },
textColor: "#b0b0b0",
attributionLogo: false,
},
grid: {
vertLines: { color: "#1a2030" },
horzLines: { color: "#1a2030" },
},
crosshair: { mode: CrosshairMode.Normal },
timeScale: { borderColor: "#1d2530", timeVisible: false },
rightPriceScale: { borderColor: "#1d2530" },
localization: { priceFormatter: (price: number) => formatMoney(price, currency) },
});
chartRef.current = chart;
if (benchmark && benchmark.equityCurve.length > 0) {
const benchSeries = chart.addSeries(LineSeries, {
color: "#7c8694",
lineWidth: 1,
lineStyle: 2,
title: benchmark.name,
crosshairMarkerVisible: false,
priceLineVisible: false,
});
benchSeries.setData(
benchmark.equityCurve.map((p) => ({ time: p.date as Time, value: p.value })),
);
}
const eqSeries = chart.addSeries(LineSeries, {
color: "#4db6ff",
lineWidth: 2,
priceLineVisible: false,
});
const data: LineData<Time>[] = equityCurve.map((p) => ({
time: p.date as Time,
value: p.value,
}));
eqSeries.setData(data);
if (initialCapital && equityCurve.length > 0) {
eqSeries.createPriceLine({
price: initialCapital,
color: "#5e6a78",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "初始",
});
}
chart.timeScale().fitContent();
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w) chart.applyOptions({ width: w });
});
ro.observe(containerRef.current);
return () => {
ro.disconnect();
chart.remove();
chartRef.current = null;
};
}, [equityCurve, initialCapital, currency, benchmark]);
return (
<div className="panel chartPanel">
<div className="panelHeader">
<div>
<p className="eyebrow">Live Equity</p>
<h2>资产曲线</h2>
<p className="hint">
{equityCurve.length} 天数据
{equityCurve.length > 0
? ` · ${equityCurve[0].date} → ${equityCurve[equityCurve.length - 1].date}`
: ""}
{benchmark ? ` · 虚线为 ${benchmark.name}(同期,按初始资金归一)` : " · 基准数据暂缺"}
</p>
</div>
</div>
<div ref={containerRef} className="liveEquityChart" />
</div>
);
}
const DEFAULT_PAGE_SIZE = 30;
const PAGE_SIZE_OPTIONS = [30, 50, 100];
type PaginationState = { page: number; pageSize: number };
function usePagination(total: number, deps: unknown[] = []): [
PaginationState,
React.Dispatch<React.SetStateAction<PaginationState>>,
{ totalPages: number; sliceStart: number; sliceEnd: number },
] {
const [state, setState] = useState<PaginationState>({
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
});
// Reset to page 1 whenever the underlying data identity changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => setState((s) => ({ ...s, page: 1 })), deps);
const totalPages = Math.max(1, Math.ceil(total / state.pageSize));
const safePage = Math.min(state.page, totalPages);
const sliceStart = (safePage - 1) * state.pageSize;
const sliceEnd = Math.min(total, sliceStart + state.pageSize);
return [
{ ...state, page: safePage },
setState,
{ totalPages, sliceStart, sliceEnd },
];
}
function PaginationBar({
page,
pageSize,
totalPages,
total,
sliceStart,
sliceEnd,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageSize: number;
totalPages: number;
total: number;
sliceStart: number;
sliceEnd: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}) {
if (total === 0) return null;
return (
<div className="paginationBar">
<span className="paginationRange">
{sliceStart + 1}–{sliceEnd} / {total}
</span>
<div className="paginationControls">
<button
className="paginationButton"
onClick={() => onPageChange(1)}
disabled={page <= 1}
>
«
</button>
<button
className="paginationButton"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
‹
</button>
<span className="paginationPage">
{page} / {totalPages}
</span>
<button
className="paginationButton"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
›
</button>
<button
className="paginationButton"
onClick={() => onPageChange(totalPages)}
disabled={page >= totalPages}
>
»
</button>
<select
className="ghostSelect ghostSelectCompact"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
>
{PAGE_SIZE_OPTIONS.map((sz) => (
<option key={sz} value={sz}>
{sz} / 页
</option>
))}
</select>
</div>
</div>
);
}
function HoldingsPanel({
holdings,
cash,
currentValue,
currency,
}: {
holdings: LiveStrategyState["holdingsDetail"];
cash: number;
currentValue: number;
currency: StrategyCurrency;
}) {
const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
usePagination(holdings.length, [holdings]);
const visible = holdings.slice(sliceStart, sliceEnd);
const cashWeight = currentValue ? cash / currentValue : 0;
return (
<div className="panel holdingsPanel">
<div className="panelHeader">
<h2>当前持仓</h2>
<span>{holdings.length} 只</span>
</div>
{holdings.length === 0 ? (
<p className="hint">当前空仓,全部为现金。</p>
) : (
<>
<table className="holdingsTable">
<thead>
<tr>
<th>标的</th>
<th>持股</th>
<th>最新价</th>
<th>市值</th>
<th>占组合</th>
</tr>
</thead>
<tbody>
{visible.map((h) => {
const weight = currentValue ? (h.marketValue ?? 0) / currentValue : 0;
return (
<tr key={h.ticker}>
<td>
<strong>{h.ticker}</strong>
</td>
<td>{h.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
<td>
{money(h.lastPrice, currency, 2)}
{h.priceStale && (
<span className="staleBadge" title="最新行情缺失,使用最近一次记录价格">价格未更新</span>
)}
</td>
<td>{money(h.marketValue, currency)}</td>
<td>{pct(weight)}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="holdingsCashRow">
<td>
<strong>现金</strong>
</td>
<td>-</td>
<td>-</td>
<td>{money(cash, currency)}</td>
<td>{pct(cashWeight)}</td>
</tr>
</tfoot>
</table>
<PaginationBar
page={page}
pageSize={pageSize}
totalPages={totalPages}
total={holdings.length}
sliceStart={sliceStart}
sliceEnd={sliceEnd}
onPageChange={(p) => setPage((s) => ({ ...s, page: p }))}
onPageSizeChange={(sz) => setPage(() => ({ page: 1, pageSize: sz }))}
/>
</>
)}
</div>
);
}
function TradeLogPanel({ log, currency }: { log: TradeLogEntry[]; currency: StrategyCurrency }) {
// Render newest-first; the trader logs trades chronologically.
const reversed = useMemo(() => [...log].reverse(), [log]);
const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
usePagination(reversed.length, [log]);
const visible = reversed.slice(sliceStart, sliceEnd);
return (
<div className="panel tradeLogPanel">
<div className="panelHeader">
<h2>交易历史</h2>
<span>{log.length} 笔</span>
</div>
{log.length === 0 ? (
<p className="hint">尚无交易记录。</p>
) : (
<>
<table className="tradeLogTable">
<thead>
<tr>
<th>日期</th>
<th>方向</th>
<th>标的</th>
<th>股数</th>
<th>成交价</th>
<th>金额</th>
<th>手续费</th>
</tr>
</thead>
<tbody>
{visible.map((t, i) => (
<tr
key={`${t.date}-${t.ticker}-${sliceStart + i}`}
className={t.action === "BUY" ? "buy" : "sell"}
>
<td>{t.date}</td>
<td>
<span className={`tradeAction ${t.action.toLowerCase()}`}>
{t.action === "BUY" ? "买入" : "卖出"}
</span>
</td>
<td>
<strong>{t.ticker}</strong>
</td>
<td>{t.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
<td>{money(t.price, currency, 2)}</td>
<td>{money(t.value, currency)}</td>
<td>{money(t.commission, currency, 2)}</td>
</tr>
))}
</tbody>
</table>
<PaginationBar
page={page}
pageSize={pageSize}
totalPages={totalPages}
total={log.length}
sliceStart={sliceStart}
sliceEnd={sliceEnd}
onPageChange={(p) => setPage((s) => ({ ...s, page: p }))}
onPageSizeChange={(sz) => setPage(() => ({ page: 1, pageSize: sz }))}
/>
</>
)}
</div>
);
}
const money = formatMoney;
const pct = formatPct;

View File

@@ -0,0 +1,984 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
ColorType,
CrosshairMode,
LineSeries,
createChart,
type IChartApi,
type ISeriesApi,
type LineData,
type Time,
} from "lightweight-charts";
import { ArrowDown, ArrowUp, BarChart3, Trophy, TrendingDown, TrendingUp } from "lucide-react";
import {
calendarDaysSince,
currencyForMarket,
formatMoney,
formatNumber,
formatPct,
type BacktestResult,
type PrecomputedIndex,
type PrecomputedScenarioInfo,
type StrategyInfo,
type StrategyMarket,
type StrategyMetrics,
type StrategyResult,
} from "../shared/strategyTypes";
const STRATEGY_COLORS = [
"#4db6ff", "#f3c969", "#c084fc", "#26a69a", "#ef5350",
"#ff9800", "#8bc34a", "#e91e63", "#00bcd4", "#cddc39",
];
const MAX_CHART_SELECTION = 10;
// Precomputed data older than this (calendar days) is flagged as stale.
const STALE_DATA_DAYS = 7;
type RankMetric =
| "annualizedReturn"
| "sharpeRatio"
| "sortinoRatio"
| "calmarRatio"
| "maxDrawdown"
| "winRate"
| "rebalanceWinRate"
| "totalReturn";
const RANK_METRICS: Array<{ key: RankMetric; label: string; better: "high" | "low" }> = [
{ key: "sharpeRatio", label: "Sharpe", better: "high" },
{ key: "annualizedReturn", label: "年化", better: "high" },
{ key: "totalReturn", label: "总收益", better: "high" },
{ key: "calmarRatio", label: "Calmar", better: "high" },
{ key: "sortinoRatio", label: "Sortino", better: "high" },
// maxDrawdown is negative: closer to zero (higher) is better.
{ key: "maxDrawdown", label: "MaxDD", better: "high" },
{ key: "winRate", label: "日胜率", better: "high" },
{ key: "rebalanceWinRate", label: "再平衡胜率", better: "high" },
];
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!response.ok) {
const body = (await response.json().catch(() => undefined)) as { error?: string } | undefined;
throw new Error(body?.error ?? `Request failed: ${response.status}`);
}
return (await response.json()) as T;
}
export function StrategyPage() {
const [strategies, setStrategies] = useState<StrategyInfo[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [market, setMarket] = useState<StrategyMarket>("us");
const [scenarioList, setScenarioList] = useState<PrecomputedScenarioInfo[]>([]);
const [scenarioId, setScenarioId] = useState<string | null>(null);
const [precomputed, setPrecomputed] = useState<BacktestResult | undefined>();
const [precomputedError, setPrecomputedError] = useState("");
const [error, setError] = useState("");
const [focusStrategy, setFocusStrategy] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [rankMetric, setRankMetric] = useState<RankMetric>("sharpeRatio");
const [loading, setLoading] = useState(true);
useEffect(() => {
api<StrategyInfo[]>("/api/strategies")
.then(setStrategies)
.catch((err) => setError(err.message));
}, []);
useEffect(() => {
api<PrecomputedIndex>("/api/strategies/precomputed/index")
.then((idx) => {
setScenarioList(idx.scenarios);
if (idx.scenarios.length > 0 && !scenarioId) {
setScenarioId(idx.scenarios[0].id);
}
})
.catch((err) => setPrecomputedError(err instanceof Error ? err.message : "无法加载场景列表"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Prefer the scenario the server says it served over the requested one.
const servedScenarioId = precomputed?.scenarioId ?? scenarioId;
const currentScenario = useMemo(
() => scenarioList.find((s) => s.id === servedScenarioId) ?? null,
[scenarioList, servedScenarioId],
);
useEffect(() => {
if (!scenarioId) return;
setLoading(true);
setPrecomputedError("");
setPrecomputed(undefined);
api<BacktestResult>(`/api/strategies/precomputed?market=${market}&scenario=${encodeURIComponent(scenarioId)}`)
.then((res) => {
setPrecomputed(res);
const ranked = rankBy(res.strategies, rankMetric).filter((s) => !s.ruined);
const top = new Set(ranked.slice(0, 5).map((s) => s.name));
setSelected(top);
if (ranked.length > 0) setFocusStrategy(ranked[0].name);
})
.catch((err) => {
setPrecomputedError(err instanceof Error ? err.message : "无法加载预计算结果");
})
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [market, scenarioId]);
const categoryByName = useMemo(() => {
const map = new Map<string, string>();
for (const s of strategies) map.set(s.name, s.category);
return map;
}, [strategies]);
const categories = useMemo(() => {
if (!precomputed) return ["all"];
const seen = new Set<string>();
for (const s of precomputed.strategies) {
seen.add(categoryByName.get(s.name) ?? "other");
}
return ["all", ...Array.from(seen).sort()];
}, [precomputed, categoryByName]);
const filteredStrategies = useMemo(() => {
if (!precomputed) return [];
if (categoryFilter === "all") return precomputed.strategies;
return precomputed.strategies.filter(
(s) => (categoryByName.get(s.name) ?? "other") === categoryFilter,
);
}, [precomputed, categoryFilter, categoryByName]);
const rankedStrategies = useMemo(
() => rankBy(filteredStrategies, rankMetric),
[filteredStrategies, rankMetric],
);
const rankIndex = useMemo(() => {
const map = new Map<string, number>();
rankedStrategies.forEach((s, i) => map.set(s.name, i + 1));
return map;
}, [rankedStrategies]);
const chartResult: BacktestResult | undefined = useMemo(() => {
if (!precomputed) return undefined;
return {
...precomputed,
strategies: rankedStrategies.filter((s) => selected.has(s.name)),
};
}, [precomputed, rankedStrategies, selected]);
function toggleStrategy(name: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
if (next.size >= MAX_CHART_SELECTION) return prev;
next.add(name);
}
return next;
});
setFocusStrategy(name);
}
function selectTopN(metric: RankMetric, n: number) {
if (!precomputed) return;
const ranked = rankBy(filteredStrategies, metric).filter((s) => !s.ruined);
const top = new Set(ranked.slice(0, n).map((s) => s.name));
setSelected(top);
if (ranked.length > 0) setFocusStrategy(ranked[0].name);
setRankMetric(metric);
}
function clearSelection() {
setSelected(new Set());
}
const meta = precomputed?.meta;
const computedAt = precomputed?.computedAt ? new Date(precomputed.computedAt) : null;
const dataAgeDays = calendarDaysSince(meta?.endDate);
const dataStale = dataAgeDays !== null && dataAgeDays > STALE_DATA_DAYS;
return (
<div className="strategyPage">
{error && <div className="alert">{error}</div>}
{precomputedError && <div className="alert">{precomputedError}</div>}
{dataStale && (
<div className="alert warning">
预计算数据截至 {meta?.endDate},已滞后 {dataAgeDays} 天。在 quant 项目重新运行 <code>precompute_backtest.py</code> 以更新。
</div>
)}
<div className="strategyLayout">
{/* Left: Strategy selector */}
<aside className="panel strategySelector">
<div className="panelHeader">
<h2>策略选择</h2>
<span>{selected.size} / {filteredStrategies.length}</span>
</div>
<div className="precomputedHeader">
<div className="precomputedHeaderRow">
<label>市场</label>
<div className="segmented small">
<button className={market === "us" ? "active" : ""} onClick={() => setMarket("us")}>美股</button>
<button className={market === "cn" ? "active" : ""} onClick={() => setMarket("cn")}>A股</button>
</div>
</div>
{scenarioList.length > 0 && (
<div className="scenarioPicker">
<span className="scenarioPickerLabel">投入方式</span>
<div className="scenarioOptions">
{scenarioList.map((sc) => (
<button
key={sc.id}
className={`scenarioOption ${scenarioId === sc.id ? "active" : ""}`}
onClick={() => setScenarioId(sc.id)}
type="button"
title={scenarioDescription(sc, market)}
>
<strong>{scenarioLabel(sc, market)}</strong>
<span>{summarizeScenario(sc, market)}</span>
</button>
))}
</div>
</div>
)}
{meta && (
<p className="precomputedHint">
{meta.years} 年 · 离线预计算 · 数据截至 {meta.endDate ?? "—"}
{computedAt ? ` · 计算于 ${computedAt.toLocaleDateString()}` : ""}
{dataStale && <span className="staleBadge">数据滞后 {dataAgeDays} 天</span>}
</p>
)}
</div>
<div className="quickPicks">
<span className="quickPicksLabel">快速选取</span>
<div className="quickPicksRow">
<button className="quickPickButton" onClick={() => selectTopN("sharpeRatio", 5)}>
<Trophy size={12} /> Top 5 Sharpe
</button>
<button className="quickPickButton" onClick={() => selectTopN("annualizedReturn", 5)}>
<Trophy size={12} /> Top 5 年化
</button>
<button className="quickPickButton ghost" onClick={clearSelection}>清空</button>
</div>
</div>
{categories.length > 1 && (
<div className="tagFilter">
{categories.map((cat) => (
<button
className={categoryFilter === cat ? "tagChip active" : "tagChip"}
key={cat}
onClick={() => setCategoryFilter(cat)}
>
{categoryLabel(cat)}
</button>
))}
</div>
)}
<div className="strategyList">
{loading && <p className="strategyEmpty">加载预计算结果中...</p>}
{!loading && rankedStrategies.length === 0 && (
<p className="strategyEmpty">当前筛选下暂无策略</p>
)}
{rankedStrategies.map((s, i) => {
const isSelected = selected.has(s.name);
const colorIdx = chartResult?.strategies.findIndex((x) => x.name === s.name) ?? -1;
const color = colorIdx >= 0 ? STRATEGY_COLORS[colorIdx % STRATEGY_COLORS.length] : undefined;
return (
<label
className={`strategyItem ${isSelected ? "selected" : ""}`}
key={s.name}
title={s.name}
>
<span className="strategyRank" data-top={i < 3 && !s.ruined ? "1" : "0"}>#{i + 1}</span>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleStrategy(s.name)}
/>
<span
className="strategyDot"
style={{ background: color ?? "transparent", borderColor: color ?? "#2a3344" }}
/>
<span className="strategyLabel">
{displayStrategyName(s)}
{s.ruined && <RuinedBadge />}
</span>
<span className={`strategyMetric ${signClass(s.metrics.annualizedReturn)}`}>
{pct(s.metrics.annualizedReturn)}
</span>
<span className="strategyMetricMuted">
{num(s.metrics.sharpeRatio)}
</span>
</label>
);
})}
</div>
</aside>
{/* Right: Results */}
<section className="strategyResults">
{chartResult && precomputed ? (
<>
<EquityCurveChart
result={chartResult}
focusStrategy={focusStrategy}
onFocus={setFocusStrategy}
scenario={currentScenario}
/>
<RankingLeaderboard
strategies={rankedStrategies}
selected={selected}
onToggle={toggleStrategy}
onFocus={setFocusStrategy}
rankMetric={rankMetric}
onChangeMetric={setRankMetric}
categoryByName={categoryByName}
rankIndex={rankIndex}
timeWeighted={hasInjections(chartResult)}
/>
<div className="insightGrid">
<MetricsPanel result={chartResult} focusStrategy={focusStrategy} onFocus={setFocusStrategy} />
<DrawdownInfo result={chartResult} focusStrategy={focusStrategy} />
</div>
<ReturnHeatmap result={chartResult} focusStrategy={focusStrategy} />
</>
) : (
<div className="panel emptyState">
<TrendingUp size={48} />
<p>{loading ? "正在加载预计算回测结果..." : "暂无预计算回测结果"}</p>
<p className="hint">在 quant 项目运行 <code>precompute_backtest.py</code> 后即可查看</p>
</div>
)}
</section>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Ranking Leaderboard
// ---------------------------------------------------------------------------
function RankingLeaderboard({
strategies,
selected,
onToggle,
onFocus,
rankMetric,
onChangeMetric,
categoryByName,
rankIndex,
timeWeighted,
}: {
strategies: StrategyResult[];
selected: Set<string>;
onToggle: (name: string) => void;
onFocus: (name: string) => void;
rankMetric: RankMetric;
onChangeMetric: (metric: RankMetric) => void;
categoryByName: Map<string, string>;
rankIndex: Map<string, number>;
timeWeighted: boolean;
}) {
return (
<div className="panel rankingPanel">
<div className="panelHeader">
<div>
<p className="eyebrow">Leaderboard</p>
<h2>策略排名</h2>
<p>
点击行加入/移除对比;列头切换排序指标
{timeWeighted ? ";收益/风险指标为时间加权 (TWR),不含注资影响" : ""}
</p>
</div>
<span>{strategies.length} 个策略</span>
</div>
<div className="rankingWrap">
<table className="rankingTable">
<thead>
<tr>
<th className="rankCol">#</th>
<th>策略</th>
<th>类别</th>
{RANK_METRICS.map((m) => {
const active = rankMetric === m.key;
return (
<th
key={m.key}
className={`sortable ${active ? "active" : ""}`}
onClick={() => onChangeMetric(m.key)}
title={`按 ${m.label} 排序`}
>
{m.label}
{active && (m.better === "high" ? <ArrowDown size={11} /> : <ArrowUp size={11} />)}
</th>
);
})}
</tr>
</thead>
<tbody>
{strategies.map((s) => {
const isSelected = selected.has(s.name);
const rank = rankIndex.get(s.name) ?? 0;
return (
<tr
key={s.name}
className={`${isSelected ? "selected" : ""}`}
onClick={() => {
onToggle(s.name);
onFocus(s.name);
}}
>
<td className="rankCol">
<span className={`rankBadge ${rank <= 3 && !s.ruined ? "top" : ""}`}>{rank}</span>
</td>
<td>
<strong>{displayStrategyName(s)}</strong>
{s.ruined && <RuinedBadge />}
<span className="rankSub">{s.name}</span>
</td>
<td>
<span className="categoryChip">
{categoryLabel(categoryByName.get(s.name) ?? "other")}
</span>
</td>
<td>{num(s.metrics.sharpeRatio)}</td>
<td className={signClass(s.metrics.annualizedReturn)}>
{pct(s.metrics.annualizedReturn)}
</td>
<td className={signClass(s.metrics.totalReturn)}>
{pct(s.metrics.totalReturn)}
</td>
<td>{num(s.metrics.calmarRatio)}</td>
<td>{num(s.metrics.sortinoRatio)}</td>
<td className="negative">{pct(s.metrics.maxDrawdown)}</td>
<td>{pct(s.metrics.winRate)}</td>
<td>{pct(s.metrics.rebalanceWinRate)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Equity Curve Chart
// ---------------------------------------------------------------------------
function EquityCurveChart({
result,
focusStrategy,
onFocus,
scenario,
}: {
result: BacktestResult;
focusStrategy: string | null;
onFocus: (name: string) => void;
scenario: PrecomputedScenarioInfo | null;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesMapRef = useRef<Map<string, ISeriesApi<"Line">>>(new Map());
const currency = currencyForMarket(result.meta.market);
useEffect(() => {
if (!containerRef.current) return;
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height: 480,
layout: {
background: { type: ColorType.Solid, color: "#0d1219" },
textColor: "#b0b0b0",
attributionLogo: false,
},
grid: {
vertLines: { color: "#1a2030" },
horzLines: { color: "#1a2030" },
},
crosshair: { mode: CrosshairMode.Normal },
timeScale: { borderColor: "#1d2530", timeVisible: false },
rightPriceScale: { borderColor: "#1d2530" },
});
chartRef.current = chart;
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width;
if (width) chart.applyOptions({ width });
});
observer.observe(containerRef.current);
return () => {
observer.disconnect();
chart.remove();
chartRef.current = null;
seriesMapRef.current.clear();
};
}, []);
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
chart.applyOptions({
localization: { priceFormatter: (price: number) => formatMoney(price, currency) },
});
for (const series of seriesMapRef.current.values()) {
chart.removeSeries(series);
}
seriesMapRef.current.clear();
result.strategies.forEach((strat, i) => {
const color = STRATEGY_COLORS[i % STRATEGY_COLORS.length];
const lineWidth = focusStrategy === strat.name ? 3 : 1.6;
const series = chart.addSeries(LineSeries, {
color,
lineWidth: lineWidth as 1 | 2 | 3 | 4,
crosshairMarkerVisible: true,
priceLineVisible: false,
});
const data: LineData<Time>[] = strat.equityCurve.map((p) => ({
time: p.date as Time,
value: p.value,
}));
series.setData(data);
seriesMapRef.current.set(strat.name, series);
});
if (result.benchmark.equityCurve.length > 0) {
const benchSeries = chart.addSeries(LineSeries, {
color: "#7c8694",
lineWidth: 1,
lineStyle: 2,
title: result.benchmark.name,
crosshairMarkerVisible: false,
priceLineVisible: false,
});
benchSeries.setData(
result.benchmark.equityCurve.map((p) => ({ time: p.date as Time, value: p.value })),
);
seriesMapRef.current.set("__benchmark__", benchSeries);
}
chart.timeScale().fitContent();
}, [result, focusStrategy, currency]);
return (
<div className="panel chartPanel">
<div className="equityHeader">
<div className="equityTitle">
<p className="eyebrow">Equity Curves</p>
<h2>资产曲线对比</h2>
</div>
<div className="equityMeta">
<span className="equityMetaItem">{result.meta.market.toUpperCase()}</span>
<span className="equityMetaSep">·</span>
<span className="equityMetaItem">{result.meta.years} 年</span>
<span className="equityMetaSep">·</span>
<span className="equityMetaItem">{formatMoney(result.meta.initialCapital, currency)} 初始</span>
{scenario?.injectionSchedule && (
<>
<span className="equityMetaSep">·</span>
<span className="equityMetaItem">{summarizeScenario(scenario, result.meta.market)}</span>
</>
)}
</div>
</div>
<div className="legendRow">
{result.strategies.length === 0 && (
<span className="legendEmpty">从左侧选取策略加入对比</span>
)}
{result.strategies.map((strat, i) => (
<button
className={`legendItem ${focusStrategy === strat.name ? "focused" : ""}`}
key={strat.name}
onClick={() => onFocus(strat.name)}
type="button"
>
<span className="legendDot" style={{ background: STRATEGY_COLORS[i % STRATEGY_COLORS.length] }} />
{displayStrategyName(strat)}
</button>
))}
{result.benchmark.equityCurve.length ? (
<span className="legendItem benchmarkLegend">
<span className="legendDot" />
{result.benchmark.name}
</span>
) : null}
</div>
<div ref={containerRef} className="equityChartContainer" />
</div>
);
}
// ---------------------------------------------------------------------------
// Metrics Panel
// ---------------------------------------------------------------------------
function MetricsPanel({
result,
focusStrategy,
onFocus,
}: {
result: BacktestResult;
focusStrategy: string | null;
onFocus: (name: string) => void;
}) {
const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
const currency = currencyForMarket(result.meta.market);
// With cash injections the metrics are time-weighted; the money view comes
// from capitalFlows (never recomputed from the money curve here).
const timeWeighted = hasInjections(result);
const twr = timeWeighted ? " (TWR)" : "";
const showFlows = timeWeighted && result.strategies.some((s) => s.capitalFlows);
const flows = focused?.capitalFlows;
return (
<div className="panel metricsPanel">
<div className="panelHeader">
<h2>核心指标</h2>
{focused && (
<span>
{displayStrategyName(focused)}
{focused.ruined && <RuinedBadge />}
</span>
)}
</div>
{focused ? (
<>
{timeWeighted && flows && (
<>
<p className="hint">资金视角(含注资)</p>
<div className="metricsGrid">
<MetricCell label="投入本金" value={formatMoney(flows.invested, currency)} />
<MetricCell label="期末市值" value={formatMoney(flows.finalValue, currency)} />
<MetricCell
label="盈亏"
value={formatMoney(flows.profit, currency)}
positive={flows.profit > 0}
negative={flows.profit < 0}
/>
<MetricCell
label="本金收益率"
value={pct(flows.returnOnInvested)}
positive={isPositive(flows.returnOnInvested)}
negative={isNegative(flows.returnOnInvested)}
/>
</div>
</>
)}
{timeWeighted && <p className="hint">收益/风险指标为时间加权 (TWR),剔除注资影响</p>}
<div className="metricsGrid">
<MetricCell label={`总收益${twr}`} value={pct(focused.metrics.totalReturn)} positive={isPositive(focused.metrics.totalReturn)} />
<MetricCell label={`年化收益${twr}`} value={pct(focused.metrics.annualizedReturn)} positive={isPositive(focused.metrics.annualizedReturn)} />
<MetricCell label="年化波动" value={pct(focused.metrics.annualizedVolatility)} />
<MetricCell label="Sharpe" value={num(focused.metrics.sharpeRatio)} positive={isAbove(focused.metrics.sharpeRatio, 1)} />
<MetricCell label="Sortino" value={num(focused.metrics.sortinoRatio)} positive={isAbove(focused.metrics.sortinoRatio, 1)} />
<MetricCell label="最大回撤" value={pct(focused.metrics.maxDrawdown)} negative />
<MetricCell label="Calmar" value={num(focused.metrics.calmarRatio)} positive={isAbove(focused.metrics.calmarRatio, 0.5)} />
<MetricCell label="日胜率" value={pct(focused.metrics.winRate)} positive={isAbove(focused.metrics.winRate, 0.5)} />
<MetricCell
label="再平衡胜率"
value={pct(focused.metrics.rebalanceWinRate)}
positive={isAbove(focused.metrics.rebalanceWinRate, 0.5)}
/>
</div>
<div className="focusSummary">
<span><BarChart3 size={15} /> 终值 {formatMoney(flows?.finalValue ?? focused.equityCurve.at(-1)?.value, currency)}</span>
<span><TrendingDown size={15} /> 最大回撤 {pct(focused.metrics.maxDrawdown)}</span>
<span><TrendingUp size={15} /> 年化{twr} {pct(focused.metrics.annualizedReturn)}</span>
<span>交易 {focused.trades.length.toLocaleString()} 天</span>
</div>
</>
) : (
<p className="hint">从排名表选取至少一个策略查看核心指标</p>
)}
{result.strategies.length > 1 && (
<div className="comparisonWrap">
<table className="comparisonTable">
<thead>
<tr>
<th>策略</th>
{showFlows && (
<>
<th>投入本金</th>
<th>期末市值</th>
<th>盈亏</th>
<th>本金收益率</th>
</>
)}
<th>年化{twr}</th>
<th>Sharpe</th>
<th>MaxDD</th>
<th>日胜率</th>
<th>再平衡胜率</th>
</tr>
</thead>
<tbody>
{result.strategies.map((s) => (
<tr key={s.name} className={s.name === focusStrategy ? "focused" : ""} onClick={() => onFocus(s.name)}>
<td>
<strong>{displayStrategyName(s)}</strong>
{s.ruined && <RuinedBadge />}
<span>{s.name}</span>
</td>
{showFlows && (
<>
<td>{formatMoney(s.capitalFlows?.invested, currency)}</td>
<td>{formatMoney(s.capitalFlows?.finalValue, currency)}</td>
<td className={signClass(s.capitalFlows?.profit)}>{formatMoney(s.capitalFlows?.profit, currency)}</td>
<td className={signClass(s.capitalFlows?.returnOnInvested)}>{pct(s.capitalFlows?.returnOnInvested)}</td>
</>
)}
<td className={signClass(s.metrics.annualizedReturn)}>{pct(s.metrics.annualizedReturn)}</td>
<td>{num(s.metrics.sharpeRatio)}</td>
<td className="negative">{pct(s.metrics.maxDrawdown)}</td>
<td>{pct(s.metrics.winRate)}</td>
<td>{pct(s.metrics.rebalanceWinRate)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function MetricCell({ label, value, positive, negative }: { label: string; value: string; positive?: boolean; negative?: boolean }) {
const cls = positive ? "positive" : negative ? "negative" : "";
return (
<div className={`metricCell ${cls}`}>
<span className="metricValue">{value}</span>
<span className="metricLabel">{label}</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Drawdown Info
// ---------------------------------------------------------------------------
function DrawdownInfo({ result, focusStrategy }: { result: BacktestResult; focusStrategy: string | null }) {
const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
if (!focused) {
return (
<div className="panel drawdownPanel">
<div className="panelHeader"><h2>重大回撤</h2></div>
<p className="hint">无聚焦策略</p>
</div>
);
}
const topDrawdowns = [...focused.drawdowns].sort((a, b) => a.depth - b.depth).slice(0, 5);
return (
<div className="panel drawdownPanel">
<div className="panelHeader">
<h2>重大回撤</h2>
<span>{displayStrategyName(focused)}</span>
</div>
{topDrawdowns.length === 0 ? (
<p className="hint">无显著回撤期间 (阈值 -5%)</p>
) : (
<table className="drawdownTable">
<thead>
<tr>
<th>开始</th>
<th>结束</th>
<th>深度</th>
<th>恢复</th>
</tr>
</thead>
<tbody>
{topDrawdowns.map((dd, i) => (
<tr key={i}>
<td>{dd.start}</td>
<td>{dd.end}</td>
<td className="negative">{pct(dd.depth)}</td>
<td>{dd.recoveryDate ?? "未恢复"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Return Heatmap
// ---------------------------------------------------------------------------
function ReturnHeatmap({ result, focusStrategy }: { result: BacktestResult; focusStrategy: string | null }) {
const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
if (!focused) return null;
const { monthlyReturns, yearlyReturns } = focused;
const years = Object.keys(yearlyReturns).sort();
const months = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"];
const monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return (
<div className="panel heatmapPanel">
<div className="panelHeader">
<h2>收益热力图{hasInjections(result) ? " (TWR)" : ""}</h2>
<span>{displayStrategyName(focused)}</span>
</div>
<div className="heatmapContainer">
<table className="heatmapTable">
<thead>
<tr>
<th>Year</th>
{monthLabels.map((m) => <th key={m}>{m}</th>)}
<th>YTD</th>
</tr>
</thead>
<tbody>
{years.map((year) => (
<tr key={year}>
<td className="yearLabel">{year}</td>
{months.map((month) => {
const key = `${year}-${month}`;
const val = finiteOrUndefined(monthlyReturns[key]);
return (
<td key={month} className="heatCell" style={{ background: heatColor(val) }}>
{val !== undefined ? `${(val * 100).toFixed(1)}` : ""}
</td>
);
})}
<td className={`yearReturn ${signClass(yearlyReturns[year])}`}>
{pct(yearlyReturns[year])}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rankBy(strategies: StrategyResult[], metric: RankMetric): StrategyResult[] {
const better = RANK_METRICS.find((m) => m.key === metric)?.better ?? "high";
const dir = better === "high" ? -1 : 1;
const get = (s: StrategyResult) => {
const v = s.metrics[metric as keyof StrategyMetrics];
return typeof v === "number" && Number.isFinite(v) ? v : (better === "high" ? -Infinity : Infinity);
};
// Ruined strategies always rank last so they never take a "best" slot.
return [...strategies].sort(
(a, b) => Number(!!a.ruined) - Number(!!b.ruined) || dir * (get(a) - get(b)),
);
}
function hasInjections(result: BacktestResult): boolean {
return !!result.meta.injectionSchedule;
}
// Scenario capital in the given market's currency. Older files only carry the
// base (US) `capital`, which then applies to every market.
function scenarioCapital(sc: PrecomputedScenarioInfo, market: StrategyMarket): number {
return sc.capitalByMarket?.[market] ?? sc.capital;
}
function summarizeScenario(sc: PrecomputedScenarioInfo, market: StrategyMarket): string {
const currency = currencyForMarket(market);
const capital = scenarioCapital(sc, market);
const fmt = (v: number) => formatMoney(v, currency);
if (!sc.injectionSchedule) return `${fmt(capital)} 一次性`;
const inj = sc.injectionSchedule;
// The index lists the injection amount in base capital; scale to this market.
const amount = sc.capital ? (inj.amount * capital) / sc.capital : inj.amount;
if (inj.frequency === "yearly_months" && inj.months?.length) {
const monthLabel = inj.months.join("/");
return `${fmt(capital)} 起 · 每年 ${monthLabel} 月注入 ${fmt(amount)}`;
}
const freqLabel = inj.frequency === "monthly" ? "每月" : inj.frequency === "quarterly" ? "每季" : "每年";
return `${fmt(capital)} 起 · ${freqLabel}注入 ${fmt(amount)}`;
}
// Older precomputed files baked USD amounts into scenario copy; don't show
// that copy for non-US markets.
function hasUsdCopy(text: string | undefined): boolean {
return !!text && /\$|美元|USD/.test(text);
}
function scenarioLabel(sc: PrecomputedScenarioInfo, market: StrategyMarket): string {
if (market === "us" || !hasUsdCopy(sc.label)) return sc.label;
return sc.injectionSchedule ? "起始资金 + 定期追加" : "起始资金一次性投入";
}
function scenarioDescription(sc: PrecomputedScenarioInfo, market: StrategyMarket): string {
if (sc.description && (market === "us" || !hasUsdCopy(sc.description))) return sc.description;
return summarizeScenario(sc, market);
}
function categoryLabel(cat: string): string {
if (cat === "all") return "全部";
if (cat === "stock_picker") return "选股";
if (cat === "factor_combo") return "因子";
if (cat === "tactical_allocation") return "配置";
if (cat === "other") return "其他";
return cat;
}
const pct = formatPct;
const num = formatNumber;
function finiteOrUndefined(val: number | null | undefined): number | undefined {
return typeof val === "number" && Number.isFinite(val) ? val : undefined;
}
function isPositive(val: number | null | undefined): boolean {
return (finiteOrUndefined(val) ?? 0) > 0;
}
function isNegative(val: number | null | undefined): boolean {
return (finiteOrUndefined(val) ?? 0) < 0;
}
function isAbove(val: number | null | undefined, threshold: number): boolean {
const v = finiteOrUndefined(val);
return v !== undefined && v > threshold;
}
function signClass(val: number | null | undefined): string {
const v = finiteOrUndefined(val);
if (v === undefined) return "";
return v >= 0 ? "positive" : "negative";
}
function RuinedBadge() {
return (
<span className="ruinedBadge" title="资产曾归零,结果不可作为可行策略参考">
爆仓/不可行
</span>
);
}
function displayStrategyName(strategy: Pick<StrategyInfo | StrategyResult, "name" | "label">): string {
const label = typeof strategy.label === "string" ? strategy.label.trim() : "";
return label || strategy.name;
}
function heatColor(val: number | undefined): string {
if (val === undefined) return "transparent";
if (val > 0) {
const intensity = Math.min(val / 0.15, 1);
return `rgba(38, 166, 154, ${0.15 + intensity * 0.6})`;
}
const intensity = Math.min(Math.abs(val) / 0.15, 1);
return `rgba(239, 83, 80, ${0.15 + intensity * 0.6})`;
}

1248
web/src/client/main.tsx Normal file

File diff suppressed because it is too large Load Diff

2505
web/src/client/styles.css Normal file

File diff suppressed because it is too large Load Diff

351
web/src/server/index.ts Normal file
View File

@@ -0,0 +1,351 @@
import { PRECOMPUTED_DIR, WEB_DATA_DIR } from "./paths";
import cors from "cors";
import express from "express";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { generateDailyReport } from "../agent/report";
import { createProvider } from "../agent/provider";
import { normalizeSymbol } from "../agent/symbols";
import { StockDataCache } from "../agent/stockDataCache";
import { WatchlistStore } from "../agent/watchlistStore";
import { loadIpadsConfig } from "./ipadsConfig";
import type { ChartPeriod, WatchStock } from "../shared/types";
import type { BacktestConfig } from "../shared/strategyTypes";
import { listStrategies, runBacktest, getCachedResult, getLiveStrategies } from "./strategyService";
const port = Number(process.env.PORT ?? 8787);
const host = process.env.HOST ?? "127.0.0.1";
const watchlistStore = new WatchlistStore(join(WEB_DATA_DIR, "watchlist.json"));
const dataProvider = createProvider(loadIpadsConfig().apiKey);
const stockDataCache = new StockDataCache(join(WEB_DATA_DIR, "cache"), dataProvider);
const app = express();
let cachedReportUpdatedAt = 0;
let cachedReport: Awaited<ReturnType<typeof generateDailyReport>> | undefined;
app.use(cors());
app.use(express.json());
app.get("/api/health", async (_request, response) => {
const config = loadIpadsConfig();
response.json({
ok: true,
providerConfigured: config.apiKeyConfigured,
baseUrl: config.baseUrl,
wireApi: config.wireApi,
scheduler: "24h"
});
});
app.get("/api/watchlist", async (_request, response) => {
response.json(await watchlistStore.list());
});
app.post("/api/watchlist", async (request, response, next) => {
try {
response.status(201).json(await watchlistStore.add(String(request.body?.symbol ?? ""), request.body?.name, parseTags(request.body?.tags)));
cachedReport = undefined;
} catch (error) {
next(error);
}
});
app.put("/api/watchlist/:symbol/tags", async (request, response, next) => {
try {
const updated = await watchlistStore.updateTags(request.params.symbol, parseTags(request.body?.tags));
cachedReport = undefined;
response.json(updated);
} catch (error) {
next(error);
}
});
app.delete("/api/watchlist/:symbol", async (request, response, next) => {
try {
await watchlistStore.remove(request.params.symbol);
cachedReport = undefined;
response.status(204).end();
} catch (error) {
next(error);
}
});
app.get("/api/stocks/:symbol/chart", async (request, response, next) => {
try {
const stock = await findWatchStock(request.params.symbol);
response.json(
request.query.refresh === "1"
? await stockDataCache.refreshChart(stock, parsePeriod(request.query.period))
: await stockDataCache.chart(stock, parsePeriod(request.query.period))
);
} catch (error) {
next(error);
}
});
app.get("/api/stocks/:symbol/fundamentals", async (request, response, next) => {
try {
response.json(await stockDataCache.fundamentals(await findWatchStock(request.params.symbol), request.query.refresh === "1"));
} catch (error) {
next(error);
}
});
app.get("/api/stocks/:symbol/related", async (request, response, next) => {
try {
response.json(await stockDataCache.relatedData(await findWatchStock(request.params.symbol), request.query.refresh === "1"));
} catch (error) {
next(error);
}
});
app.get("/api/stocks/:symbol/analysis", async (request, response, next) => {
try {
const stock = await findWatchStock(request.params.symbol);
if (request.query.refresh !== "1" && (await stockDataCache.hasAnalysis(stock))) {
const analysis = await stockDataCache.quantAnalysis(stock);
if (await stockDataCache.analysisNeedsRefresh(stock)) {
void stockDataCache.quantAnalysis(stock, true).catch((error) => {
console.error(`background analysis refresh failed for ${stock.display}`, error);
});
}
response.json(analysis);
return;
}
if (request.query.refresh !== "1") {
void stockDataCache.quantAnalysis(stock, true).catch((error) => {
console.error(`background analysis generation failed for ${stock.display}`, error);
});
response.json(stockDataCache.placeholderAnalysis(stock));
return;
}
response.json(await stockDataCache.quantAnalysis(stock, request.query.refresh === "1"));
} catch (error) {
next(error);
}
});
app.get("/api/report/daily", async (_request, response, next) => {
try {
response.json(await getDailyReport());
} catch (error) {
next(error);
}
});
app.post("/api/report/daily/refresh", async (_request, response, next) => {
try {
cachedReport = undefined;
response.json(await getDailyReport(true));
} catch (error) {
next(error);
}
});
// ---------------------------------------------------------------------------
// Strategy / Quant endpoints
// ---------------------------------------------------------------------------
app.get("/api/strategies", async (_request, response, next) => {
try {
response.json(await listStrategies());
} catch (error) {
next(error);
}
});
function loadPrecomputedFile() {
const precomputedPath = join(PRECOMPUTED_DIR, "backtest_results.json");
if (!existsSync(precomputedPath)) {
throw new Error("Pre-computed results not available. Run precompute_backtest.py first.");
}
return JSON.parse(readFileSync(precomputedPath, "utf-8"));
}
// Newer files expose `scenarios`; legacy files expose `markets` at the top level.
function resolveScenarios(raw: { scenarios?: Record<string, unknown>; markets?: Record<string, unknown> }) {
if (raw.scenarios && typeof raw.scenarios === "object") {
return raw.scenarios as Record<string, {
id: string;
label: string;
description?: string;
capital: number;
capitalByMarket?: Record<string, number>;
injectionSchedule: unknown;
markets: Record<string, unknown>;
}>;
}
// Backward compat — wrap legacy single-scenario file as one anonymous scenario.
if (raw.markets) {
return {
legacy: {
id: "legacy",
label: "默认",
capital: 100000,
injectionSchedule: null,
markets: raw.markets,
},
} as Record<string, {
id: string;
label: string;
description?: string;
capital: number;
capitalByMarket?: Record<string, number>;
injectionSchedule: unknown;
markets: Record<string, unknown>;
}>;
}
throw new Error("Pre-computed file is missing both `scenarios` and `markets`.");
}
app.get("/api/strategies/precomputed/index", (_request, response, next) => {
try {
const raw = loadPrecomputedFile();
const scenarios = resolveScenarios(raw);
const list = Object.values(scenarios).map((sc) => ({
id: sc.id,
label: sc.label,
description: sc.description,
capital: sc.capital,
capitalByMarket: sc.capitalByMarket,
injectionSchedule: sc.injectionSchedule ?? null,
availableMarkets: Object.keys(sc.markets ?? {}),
}));
response.json({ computedAt: raw.computedAt, scenarios: list });
} catch (error) {
next(error);
}
});
app.get("/api/strategies/precomputed", (_request, response, next) => {
try {
const market = String(_request.query.market ?? "us");
const requestedScenario = _request.query.scenario ? String(_request.query.scenario) : undefined;
const raw = loadPrecomputedFile();
const scenarios = resolveScenarios(raw);
const scenarioIds = Object.keys(scenarios);
// An explicit but unknown scenario is a client error; silently serving
// another scenario would mislabel the results.
if (requestedScenario && !Object.hasOwn(scenarios, requestedScenario)) {
throw new Error(`Unknown pre-computed scenario: ${requestedScenario}`);
}
const scenarioId = requestedScenario ?? scenarioIds[0];
const scenario = scenarios[scenarioId];
if (!scenario) {
throw new Error("No pre-computed scenarios available.");
}
const marketResult = scenario.markets?.[market];
if (!marketResult) {
throw new Error(`No pre-computed results for market: ${market} in scenario ${scenarioId}`);
}
response.json({
id: `precomputed_${scenarioId}_${market}`,
scenarioId,
computedAt: raw.computedAt,
...(marketResult as object),
});
} catch (error) {
next(error);
}
});
app.post("/api/strategies/backtest", async (request, response, next) => {
try {
const config = request.body as BacktestConfig;
if (!config.strategies?.length) throw new Error("strategies is required");
if (!config.market) throw new Error("market is required");
const result = await runBacktest({
strategies: config.strategies,
market: config.market,
years: config.years ?? 10,
initialCapital: config.initialCapital ?? 100000,
fixedFee: config.fixedFee ?? 2.0,
txCost: config.txCost ?? 0.001,
injectionSchedule: config.injectionSchedule,
});
response.json(result);
} catch (error) {
next(error);
}
});
app.get("/api/strategies/results/:id", async (request, response, next) => {
try {
const result = await getCachedResult(request.params.id);
if (!result) throw new Error("Result not found");
response.json(result);
} catch (error) {
next(error);
}
});
app.get("/api/strategies/live", async (request, response, next) => {
try {
const market = String(request.query.market ?? "us");
const strategy = request.query.strategy ? String(request.query.strategy) : undefined;
response.json(await getLiveStrategies(market, strategy));
} catch (error) {
next(error);
}
});
app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
const message = error instanceof Error ? error.message : "Unknown server error";
response.status(400).json({ error: message });
});
setInterval(() => {
void getDailyReport().catch((error) => {
console.error("daily report refresh failed", error);
});
}, 24 * 60 * 60 * 1000).unref();
app.listen(port, host, () => {
console.log(`stock agent api listening at http://${host}:${port}`);
});
async function getDailyReport(forceSnapshots = false) {
const now = Date.now();
if (!forceSnapshots && cachedReport && now - cachedReportUpdatedAt < 24 * 60 * 60 * 1000) {
return cachedReport;
}
const watchlist = await watchlistStore.list();
cachedReport = await generateDailyReport(
watchlist,
{
snapshot: (stock) => stockDataCache.snapshot(stock, forceSnapshots),
candles: (stock) => dataProvider.candles(stock),
fundamentals: (stock) => dataProvider.fundamentals(stock),
relatedData: (stock) => dataProvider.relatedData(stock),
quantAnalysis: (input) => dataProvider.quantAnalysis(input)
},
new Date()
);
cachedReportUpdatedAt = now;
return cachedReport;
}
async function findWatchStock(symbolInput: string): Promise<WatchStock> {
const normalized = normalizeSymbol(symbolInput);
const stock = (await watchlistStore.list()).find((item) => item.display === normalized.display);
if (!stock) {
throw new Error("Stock is not in watchlist");
}
return stock;
}
function parseTags(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map(String);
}
if (typeof value === "string") {
return value.split(",");
}
return [];
}
function parsePeriod(value: unknown): ChartPeriod {
return value === "W" || value === "M" || value === "Y" ? value : "D";
}

View File

@@ -0,0 +1,18 @@
import { IPADS_BASE_URL, IPADS_WIRE_API } from "../agent/provider";
export interface IpadsConfig {
baseUrl: typeof IPADS_BASE_URL;
wireApi: typeof IPADS_WIRE_API;
apiKey: string;
apiKeyConfigured: boolean;
}
export function loadIpadsConfig(env: NodeJS.ProcessEnv = process.env): IpadsConfig {
const apiKey = (env.IPADS_API_KEY ?? "").trim();
return {
baseUrl: IPADS_BASE_URL,
wireApi: IPADS_WIRE_API,
apiKey,
apiKeyConfigured: Boolean(apiKey)
};
}

14
web/src/server/paths.ts Normal file
View File

@@ -0,0 +1,14 @@
import { config } from "dotenv";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// web/src/server/ → repository root, which also holds bridge.py and the Python engine.
export const REPO_ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
// Secrets such as IPADS_API_KEY live in the single untracked repo-root .env.
config({ path: join(REPO_ROOT, ".env"), quiet: true });
// Web-only state: watchlist, per-stock cache and backtest result cache.
export const WEB_DATA_DIR = join(REPO_ROOT, "data", "web");
// Written by precompute_backtest.py, read by the strategy page.
export const PRECOMPUTED_DIR = join(REPO_ROOT, "data", "precomputed");

View File

@@ -0,0 +1,178 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { REPO_ROOT, WEB_DATA_DIR } from "./paths";
import type { BacktestConfig, BacktestResult, LiveStrategyState, StrategyInfo } from "../shared/strategyTypes.js";
const CACHE_DIR = join(WEB_DATA_DIR, "strategy_cache");
const TIMEOUT_MS = Number(process.env.QUANT_BACKTEST_TIMEOUT_MS) || 180_000;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_CACHE_FILES = 50;
function ensureCacheDir(): void {
if (!existsSync(CACHE_DIR)) {
mkdirSync(CACHE_DIR, { recursive: true });
}
}
function configHash(config: BacktestConfig): string {
const serialized = stableStringify(config);
return createHash("md5").update(serialized).digest("hex").slice(0, 12);
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function runBridge(args: string[]): Promise<unknown> {
return new Promise((resolve, reject) => {
const proc = spawn("uv", ["run", "python", "bridge.py", ...args], {
cwd: REPO_ROOT,
env: { ...process.env, PYTHONUNBUFFERED: "1" },
});
const chunks: Buffer[] = [];
let stderr = "";
let exitCode: number | null = null;
let stdoutEnded = false;
let exited = false;
function tryFinish() {
if (!stdoutEnded || !exited) return;
clearTimeout(timer);
const stdout = Buffer.concat(chunks).toString("utf-8");
if (exitCode !== 0) {
reject(new Error(`bridge.py exited with code ${exitCode}: ${stderr.slice(-1000)}`));
} else {
try {
resolve(JSON.parse(stdout));
} catch {
reject(new Error(`Failed to parse bridge output (${stdout.length} bytes): ${stdout.slice(0, 200)}`));
}
}
}
proc.stdout.on("data", (chunk: Buffer) => { chunks.push(chunk); });
proc.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
proc.stdout.on("end", () => { stdoutEnded = true; tryFinish(); });
proc.on("close", (code) => { exitCode = code; exited = true; tryFinish(); });
const timer = setTimeout(() => {
proc.kill("SIGTERM");
reject(new Error(`Bridge timeout after ${TIMEOUT_MS}ms. stderr: ${stderr.slice(-500)}`));
}, TIMEOUT_MS);
proc.on("error", (err) => {
clearTimeout(timer);
reject(new Error(`Failed to spawn bridge.py: ${err.message}`));
});
});
}
function evictOldCache(): void {
ensureCacheDir();
const files = readdirSync(CACHE_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => ({ name: f, mtime: statSync(join(CACHE_DIR, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
while (files.length > MAX_CACHE_FILES) {
const old = files.pop()!;
unlinkSync(join(CACHE_DIR, old.name));
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
let cachedStrategies: { data: StrategyInfo[]; fetchedAt: number } | null = null;
export async function listStrategies(): Promise<StrategyInfo[]> {
if (cachedStrategies && Date.now() - cachedStrategies.fetchedAt < CACHE_TTL_MS) {
return cachedStrategies.data;
}
const data = (await runBridge(["list"])) as StrategyInfo[];
cachedStrategies = { data, fetchedAt: Date.now() };
return data;
}
export async function runBacktest(config: BacktestConfig): Promise<BacktestResult> {
const id = configHash(config);
ensureCacheDir();
// Check cache
const cachePath = join(CACHE_DIR, `${id}.json`);
if (existsSync(cachePath)) {
const stat = statSync(cachePath);
if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) {
return normalizeBacktestResult(JSON.parse(readFileSync(cachePath, "utf-8")) as BacktestResult);
}
}
// Run backtest
const args = [
"backtest",
"--strategies", config.strategies.join(","),
"--market", config.market,
"--years", String(config.years),
"--capital", String(config.initialCapital),
"--fixed-fee", String(config.fixedFee),
"--tx-cost", String(config.txCost),
];
if (config.injectionSchedule) {
args.push("--injection", JSON.stringify(config.injectionSchedule));
}
const raw = await runBridge(args);
const result = normalizeBacktestResult({
id,
...(raw as Omit<BacktestResult, "id" | "computedAt">),
computedAt: new Date().toISOString(),
});
// Cache result
writeFileSync(cachePath, `${JSON.stringify(result)}\n`);
evictOldCache();
return result;
}
export async function getCachedResult(id: string): Promise<BacktestResult | null> {
ensureCacheDir();
const cachePath = join(CACHE_DIR, `${id}.json`);
if (!existsSync(cachePath)) return null;
return normalizeBacktestResult(JSON.parse(readFileSync(cachePath, "utf-8")) as BacktestResult);
}
export async function getLiveStrategies(market: string, strategy?: string): Promise<LiveStrategyState[]> {
const args = ["live", "--market", market];
if (strategy) args.push("--strategy", strategy);
return (await runBridge(args)) as LiveStrategyState[];
}
function normalizeBacktestResult(result: BacktestResult): BacktestResult {
return {
...result,
strategies: result.strategies.map((strategy) => ({
...strategy,
label: typeof strategy.label === "string" && strategy.label.trim() ? strategy.label : strategy.name
})),
benchmark: {
name: result.benchmark?.name || "Benchmark",
equityCurve: result.benchmark?.equityCurve ?? []
}
};
}

View File

@@ -0,0 +1,232 @@
export type StrategyMarket = "us" | "cn";
export type StrategyCurrency = "USD" | "CNY";
export type StrategyCategory = "stock_picker" | "tactical_allocation" | "factor_combo" | "other";
export interface StrategyParam {
type: "number" | "string" | "select";
default: number | string;
options?: string[];
min?: number;
max?: number;
}
export interface StrategyInfo {
name: string;
label: string;
category: StrategyCategory;
params: Record<string, StrategyParam>;
markets: StrategyMarket[];
}
export interface InjectionSchedule {
amount: number;
frequency: "yearly" | "quarterly" | "monthly" | "yearly_months";
startYear: number;
months?: number[];
}
export interface PrecomputedScenarioInfo {
id: string;
label: string;
description?: string;
capital: number;
// Per-market starting capital in that market's currency. Absent in older
// precomputed files, where `capital` applies to every market.
capitalByMarket?: Partial<Record<StrategyMarket, number>>;
injectionSchedule: InjectionSchedule | null;
availableMarkets: StrategyMarket[];
}
export interface PrecomputedIndex {
computedAt: string;
scenarios: PrecomputedScenarioInfo[];
}
export interface BacktestConfig {
strategies: string[];
market: StrategyMarket;
years: number;
initialCapital: number;
fixedFee: number;
txCost: number;
injectionSchedule?: InjectionSchedule;
}
export interface EquityPoint {
date: string;
value: number;
}
export interface DrawdownPeriod {
start: string;
end: string;
depth: number;
recoveryDate: string | null;
}
export interface TradeEvent {
date: string;
buys: number;
sells: number;
buyTickers: string[];
sellTickers: string[];
}
export interface InjectionEvent {
date: string;
amount: number;
}
// Backtest metrics are time-weighted (computed on the no-injection curve).
// The bridge serializes NaN as null, so every field may be null.
export interface StrategyMetrics {
totalReturn: number | null;
annualizedReturn: number | null;
annualizedVolatility: number | null;
sharpeRatio: number | null;
sortinoRatio: number | null;
// Negative fraction; closer to zero is better.
maxDrawdown: number | null;
calmarRatio: number | null;
winRate: number | null;
// Fraction of inter-rebalance periods with positive return. Null for
// strategies with fewer than two rebalances in the window (buy-and-hold).
rebalanceWinRate: number | null;
}
// Money view of a run with cash injections: what was put in vs. what it is worth.
export interface CapitalFlows {
invested: number;
finalValue: number;
profit: number;
returnOnInvested: number | null;
}
export interface StrategyResult {
name: string;
label: string;
// Money curve (includes injections when a schedule exists).
equityCurve: EquityPoint[];
// Same strategy without injections; metrics are computed on this curve.
baseEquityCurve: EquityPoint[] | null;
metrics: StrategyMetrics;
capitalFlows?: CapitalFlows;
// Equity hit zero at some point; the result is not investable.
ruined?: boolean;
drawdowns: DrawdownPeriod[];
trades: TradeEvent[];
injections: InjectionEvent[];
monthlyReturns: Record<string, number>;
yearlyReturns: Record<string, number>;
}
export interface BacktestMeta {
market: StrategyMarket;
years: number;
initialCapital: number;
startDate: string | null;
endDate: string | null;
txCost: number;
fixedFee: number;
injectionSchedule: InjectionSchedule | null;
}
export interface BacktestResult {
id: string;
// Set by the precomputed endpoint to the scenario actually served.
scenarioId?: string;
meta: BacktestMeta;
strategies: StrategyResult[];
benchmark: { name: string; equityCurve: EquityPoint[] };
computedAt: string;
}
export interface HoldingDetail {
ticker: string;
shares: number;
lastPrice: number | null;
marketValue: number | null;
// Latest panel price missing; lastPrice is the last recorded trade price.
priceStale?: boolean;
}
export interface TradeLogEntry {
date: string;
action: "BUY" | "SELL";
ticker: string;
shares: number;
price: number;
value: number;
commission: number;
}
export interface LiveStrategyState {
name: string;
label: string;
category: StrategyCategory | string;
market: StrategyMarket;
currency?: StrategyCurrency;
initialCapital: number;
// First live session; strategies may start on different dates.
startDate?: string | null;
currentValue: number;
cash: number;
marketValue: number;
unrealizedPnl: number;
unrealizedPnlPct: number;
holdings: Record<string, number>;
holdingsDetail: HoldingDetail[];
priceAsOf: string | null;
// Starts with an anchor point at initialCapital one day before startDate.
equityCurve: EquityPoint[];
// Market benchmark rebased to initialCapital over the strategy's sessions.
// Empty when the price panel lacks the benchmark.
benchmark?: { name: string; equityCurve: EquityPoint[] };
metrics: StrategyMetrics | null;
drawdowns: DrawdownPeriod[];
trades: TradeEvent[];
tradeLog: TradeLogEntry[];
monthlyReturns: Record<string, number>;
yearlyReturns: Record<string, number>;
lastUpdated: string | null;
}
// ---------------------------------------------------------------------------
// Display helpers shared by the strategy pages
// ---------------------------------------------------------------------------
const MARKET_CURRENCY: Record<StrategyMarket, StrategyCurrency> = { us: "USD", cn: "CNY" };
export function currencyForMarket(market: string | undefined): StrategyCurrency {
return MARKET_CURRENCY[market as StrategyMarket] ?? "USD";
}
export function formatMoney(value: number | null | undefined, currency: StrategyCurrency, fractionDigits = 0): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return value.toLocaleString(undefined, {
style: "currency",
currency,
currencyDisplay: "narrowSymbol",
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
}
export function formatPct(value: number | null | undefined, fractionDigits = 1): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return `${(value * 100).toFixed(fractionDigits)}%`;
}
export function formatNumber(value: number | null | undefined, fractionDigits = 2): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return value.toFixed(fractionDigits);
}
// Whole calendar days from a YYYY-MM-DD date to today (local time); null if unparseable.
export function calendarDaysSince(date: string | null | undefined, now: Date = new Date()): number | null {
const match = typeof date === "string" ? /^(\d{4})-(\d{2})-(\d{2})/.exec(date) : null;
if (!match) return null;
const then = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round((today.getTime() - then.getTime()) / 86_400_000);
}

238
web/src/shared/types.ts Normal file
View File

@@ -0,0 +1,238 @@
export type Market = "CN" | "HK" | "US";
export type RecommendationAction = "buy_watch" | "hold" | "avoid";
export type ChartPeriod = "D" | "W" | "M" | "Y";
export interface NormalizedSymbol {
symbol: string;
market: Market;
display: string;
}
export interface WatchStock extends NormalizedSymbol {
id: string;
name?: string;
tags: string[];
addedAt: string;
}
export interface RelatedNews {
title: string;
url: string;
source?: string;
publishedAt?: string;
}
export interface DataSourceRef {
name: string;
url?: string;
asOf?: string;
}
export interface PeerValuation {
display: string;
pe: number;
}
export interface StockSnapshot {
price: number;
currency: string;
pe?: number;
forwardPe?: number;
revenueGrowth?: number;
profitGrowth?: number;
nextEarningsDate?: string;
peers: PeerValuation[];
news: RelatedNews[];
asOf?: string;
sources?: DataSourceRef[];
}
export interface MarketDataProvider {
snapshot(stock: WatchStock): Promise<StockSnapshot>;
candles(stock: WatchStock, startDate?: string): Promise<PriceCandle[]>;
fundamentals(stock: WatchStock): Promise<FinancialStatement[]>;
relatedData(stock: WatchStock): Promise<RelatedStockData>;
quantAnalysis(input: QuantAnalysisInput): Promise<QuantAnalysis>;
}
export interface PriceCandle {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface IndicatorPoint {
date: string;
ma5?: number;
ma20?: number;
ma60?: number;
ma120?: number;
ma200?: number;
rsi14?: number;
macd?: number;
macdSignal?: number;
macdHistogram?: number;
}
export interface StockChartData {
stock: WatchStock;
period: ChartPeriod;
candles: PriceCandle[];
indicators: IndicatorPoint[];
cachedAt: string;
}
export interface FinancialStatement {
period: string;
reportDate?: string;
revenue: number;
netIncome: number;
eps?: number;
grossMargin?: number;
operatingCashFlow?: number;
debtToAsset?: number;
}
export interface StockFundamentalsData {
stock: WatchStock;
statements: FinancialStatement[];
cachedAt: string;
}
export interface RelatedStockData {
sector?: string;
industryRank?: number;
beta?: number;
avgVolume20?: number;
volumeRatio?: number;
turnoverRate?: number;
volatility20?: number;
drawdownFromHigh?: number;
relativeStrength60?: number;
support?: number;
resistance?: number;
capitalFlow?: number;
sentimentScore?: number;
newsHeat?: number;
marketCap?: number;
enterpriseValue?: number;
sharesOutstanding?: number;
freeFloat?: number;
priceToBook?: number;
priceToSales?: number;
pegRatio?: number;
evToEbitda?: number;
dividendYield?: number;
freeCashFlowYield?: number;
grossMarginTtm?: number;
netMarginTtm?: number;
roeTtm?: number;
roaTtm?: number;
debtToEquity?: number;
currentRatio?: number;
revenueCagr3y?: number;
epsGrowthTtm?: number;
analystRating?: string;
analystTargetPrice?: number;
institutionalOwnership?: number;
insiderOwnership?: number;
shortInterestRatio?: number;
nextExDividendDate?: string;
indexMembership?: string[];
recentNews?: RelatedNews[];
socialMentions?: RelatedNews[];
socialDiscussionCount?: number;
webSentimentLabel?: string;
riskFlags: string[];
asOf?: string;
sources: DataSourceRef[];
}
export interface StockRelatedData {
stock: WatchStock;
data: RelatedStockData;
cachedAt: string;
}
export interface QuantFactor {
name: string;
score: number;
weight: number;
evidence: string;
}
export interface QuantAnalysis {
action: RecommendationAction;
score: number;
confidence: number;
horizon: string;
summary: string;
factors: QuantFactor[];
tradePlan?: QuantTradePlan;
marketContext?: string[];
riskNotes: string[];
}
export interface QuantTradePlan {
direction: "long" | "wait" | "reduce" | "avoid";
entryPrice?: number;
targetPrice?: number;
stopLoss?: number;
expectedReturnPct?: number;
invalidation?: string;
rationale: string;
}
export interface StockQuantAnalysis {
stock: WatchStock;
analysis: QuantAnalysis;
generatedAt: string;
cachedAt: string;
}
export interface QuantAnalysisInput {
stock: WatchStock;
snapshot: StockSnapshot;
candles: PriceCandle[];
indicators: IndicatorPoint[];
fundamentals: FinancialStatement[];
relatedData: RelatedStockData;
}
export interface EarningsSignal {
status: "none" | "soon" | "scheduled";
nextDate?: string;
daysUntil?: number;
}
export interface ValuationSignal {
label: "discount" | "fair" | "premium" | "unknown";
stockPe?: number;
peerAveragePe?: number;
spreadPct?: number;
}
export interface Recommendation {
action: RecommendationAction;
confidence: number;
reasons: string[];
}
export interface DailyReportItem {
stock: WatchStock;
snapshot: StockSnapshot;
earnings: EarningsSignal;
valuation: ValuationSignal;
recommendation: Recommendation;
news: RelatedNews[];
}
export interface DailyReport {
generatedAt: string;
summary: string;
items: DailyReportItem[];
}

217
web/tests/provider.test.ts Normal file
View File

@@ -0,0 +1,217 @@
import { describe, expect, it, vi } from "vitest";
import { DemoMarketDataProvider, IPADS_BASE_URL, IPADS_WIRE_API, IpadsResponsesProvider } from "../src/agent/provider";
import { loadIpadsConfig } from "../src/server/ipadsConfig";
import type { WatchStock } from "../src/shared/types";
const stock: WatchStock = {
id: "AAPL",
symbol: "AAPL",
display: "AAPL",
market: "US",
name: "Apple",
tags: ["core"],
addedAt: "2026-05-11T00:00:00.000Z"
};
describe("IpadsResponsesProvider", () => {
it("uses the fixed IPADS Responses API endpoint for agent quant analysis", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
output: [
{
type: "message",
role: "assistant",
content: [
{
type: "output_text",
text: JSON.stringify({
action: "buy_watch",
score: 72,
confidence: 0.68,
horizon: "1-3 months",
summary: "Trend and valuation are supportive.",
factors: [
{ name: "trend", score: 75, weight: 0.4, evidence: "above MA20" },
{ name: "valuation", score: 68, weight: 0.3, evidence: "reasonable" },
{ name: "risk", score: 70, weight: 0.3, evidence: "controlled" }
],
riskNotes: []
})
}
]
}
]
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
});
const provider = new IpadsResponsesProvider("secret", fetchImpl);
const analysis = await provider.quantAnalysis({
stock,
snapshot: { price: 100, currency: "USD", pe: 20, peers: [], news: [] },
candles: [{ date: "2026-05-11", open: 100, high: 102, low: 99, close: 101, volume: 1_000_000 }],
indicators: [{ date: "2026-05-11", ma20: 98 }],
fundamentals: [],
relatedData: { riskFlags: [], sources: [] }
});
expect(fetchImpl).toHaveBeenCalledWith(
`${IPADS_BASE_URL}/${IPADS_WIRE_API}`,
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bearer secret",
"Content-Type": "application/json"
}),
body: expect.stringContaining("\"model\":\"gpt-5.5\"")
})
);
const calls = fetchImpl.mock.calls as unknown as [string, RequestInit][];
const requestBody = JSON.parse(String(calls[0]?.[1].body));
expect(requestBody.input).toContain("\"symbol\":\"AAPL\"");
expect(typeof requestBody.input).toBe("string");
expect(requestBody).not.toHaveProperty("metadata");
expect(analysis.action).toBe("buy_watch");
});
it("falls back to finite local analysis when remote agent cannot analyze empty market data", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
output_text: JSON.stringify({
action: "hold",
score: 50,
confidence: 0.4,
horizon: "1-3个月",
summary: "invalid",
factors: [
{ name: "trend", score: 50, weight: 1, evidence: "empty" },
{ name: "valuation", score: 50, weight: 0, evidence: "empty" },
{ name: "risk", score: 50, weight: 0, evidence: "empty" }
],
tradePlan: {
direction: "wait",
entryPrice: null,
targetPrice: null,
stopLoss: null,
expectedReturnPct: null,
rationale: "invalid null prices"
},
riskNotes: []
})
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
});
const provider = new IpadsResponsesProvider("secret", fetchImpl);
const analysis = await provider.quantAnalysis({
stock: { ...stock, symbol: "DRAM", display: "DRAM", id: "DRAM" },
snapshot: { price: 0, currency: "USD", peers: [], news: [] },
candles: [],
indicators: [],
fundamentals: [],
relatedData: { riskFlags: [], sources: [] }
});
expect(Number.isFinite(analysis.score)).toBe(true);
expect(Number.isFinite(analysis.confidence)).toBe(true);
expect(analysis.factors.every((factor) => Number.isFinite(factor.score))).toBe(true);
expect(analysis.tradePlan?.entryPrice).toBeUndefined();
});
});
describe("loadIpadsConfig", () => {
it("reads IPADS_API_KEY from an env object and keeps fixed URL and wire API", () => {
expect(loadIpadsConfig({ IPADS_API_KEY: "secret" })).toEqual({
baseUrl: IPADS_BASE_URL,
wireApi: IPADS_WIRE_API,
apiKey: "secret",
apiKeyConfigured: true
});
});
it("reports an unconfigured API key when IPADS_API_KEY is blank", () => {
expect(loadIpadsConfig({ IPADS_API_KEY: " " })).toEqual({
baseUrl: IPADS_BASE_URL,
wireApi: IPADS_WIRE_API,
apiKey: "",
apiKeyConfigured: false
});
});
});
describe("DemoMarketDataProvider", () => {
it("returns deterministic snapshots when no provider URL is configured", async () => {
const provider = new DemoMarketDataProvider();
await expect(provider.snapshot(stock)).resolves.toEqual(
expect.objectContaining({
currency: "USD",
peers: expect.any(Array),
news: expect.any(Array)
})
);
});
it("returns chart candles and financial statements for local cached views", async () => {
const provider = new DemoMarketDataProvider();
await expect(provider.candles(stock)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
date: expect.any(String),
open: expect.any(Number),
high: expect.any(Number),
low: expect.any(Number),
close: expect.any(Number),
volume: expect.any(Number)
})
])
);
await expect(provider.fundamentals(stock)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
period: expect.any(String),
revenue: expect.any(Number),
netIncome: expect.any(Number)
})
])
);
});
it("returns related data and a structured quantitative analysis", async () => {
const provider = new DemoMarketDataProvider();
const candles = await provider.candles(stock);
const relatedData = await provider.relatedData(stock);
expect(relatedData).toEqual(
expect.objectContaining({
sector: expect.any(String),
avgVolume20: expect.any(Number),
sentimentScore: expect.any(Number)
})
);
await expect(
provider.quantAnalysis({
stock,
snapshot: await provider.snapshot(stock),
candles,
indicators: [],
fundamentals: await provider.fundamentals(stock),
relatedData
})
).resolves.toEqual(
expect.objectContaining({
action: expect.stringMatching(/buy_watch|hold|avoid/),
score: expect.any(Number),
factors: expect.any(Array),
tradePlan: expect.objectContaining({
rationale: expect.any(String)
})
})
);
});
});

116
web/tests/report.test.ts Normal file
View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { generateDailyReport } from "../src/agent/report";
import type { MarketDataProvider, WatchStock } from "../src/shared/types";
const watchlist: WatchStock[] = [
{
id: "AAPL",
symbol: "AAPL",
display: "AAPL",
market: "US",
name: "Apple",
tags: ["core"],
addedAt: "2026-05-11T00:00:00.000Z"
},
{
id: "00700.HK",
symbol: "00700",
display: "00700.HK",
market: "HK",
name: "Tencent",
tags: ["internet"],
addedAt: "2026-05-11T00:00:00.000Z"
}
];
const provider: MarketDataProvider = {
async snapshot(stock) {
if (stock.display === "AAPL") {
return {
price: 100,
currency: "USD",
pe: 12,
forwardPe: 11,
revenueGrowth: 0.08,
profitGrowth: 0.05,
nextEarningsDate: "2026-05-13",
peers: [
{ display: "MSFT", pe: 28 },
{ display: "GOOGL", pe: 24 }
],
news: [{ title: "Apple supplier raises shipment guidance", url: "https://example.test/aapl" }]
};
}
return {
price: 380,
currency: "HKD",
pe: 22,
forwardPe: 20,
revenueGrowth: 0.02,
profitGrowth: -0.01,
nextEarningsDate: "2026-06-30",
peers: [{ display: "9988.HK", pe: 18 }],
news: []
};
},
async candles() {
return [];
},
async fundamentals() {
return [];
},
async relatedData() {
return {
sector: "Technology",
avgVolume20: 1_000_000,
volumeRatio: 1,
volatility20: 0.02,
drawdownFromHigh: 0.1,
relativeStrength60: 0.04,
support: 90,
resistance: 120,
capitalFlow: 0.1,
sentimentScore: 0.6,
newsHeat: 0.5,
riskFlags: [],
sources: []
};
},
async quantAnalysis() {
return {
action: "hold",
score: 55,
confidence: 0.5,
horizon: "1-3 months",
summary: "Hold",
factors: [
{ name: "trend", score: 55, weight: 1, evidence: "neutral" },
{ name: "valuation", score: 55, weight: 0, evidence: "neutral" },
{ name: "risk", score: 55, weight: 0, evidence: "neutral" }
],
riskNotes: []
};
}
};
describe("generateDailyReport", () => {
it("ranks earnings, related news, valuation mismatch, and buy advice across markets", async () => {
const report = await generateDailyReport(watchlist, provider, new Date("2026-05-11T00:00:00.000Z"));
expect(report.generatedAt).toBe("2026-05-11T00:00:00.000Z");
expect(report.items).toHaveLength(2);
expect(report.summary).toContain("US: 1");
expect(report.summary).toContain("HK: 1");
const apple = report.items.find((item) => item.stock.display === "AAPL");
expect(apple?.earnings.status).toBe("soon");
expect(apple?.valuation.label).toBe("discount");
expect(apple?.recommendation.action).toBe("buy_watch");
expect(apple?.news[0]?.title).toContain("supplier");
const tencent = report.items.find((item) => item.stock.display === "00700.HK");
expect(tencent?.valuation.label).toBe("premium");
expect(tencent?.recommendation.action).toBe("hold");
});
});

View File

@@ -0,0 +1,179 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StockDataCache } from "../src/agent/stockDataCache";
import type { MarketDataProvider, WatchStock } from "../src/shared/types";
let tempDir: string | undefined;
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { force: true, recursive: true });
tempDir = undefined;
}
});
const stock: WatchStock = {
id: "AAPL",
symbol: "AAPL",
display: "AAPL",
market: "US",
name: "Apple",
tags: ["core"],
addedAt: "2026-05-11T00:00:00.000Z"
};
describe("StockDataCache", () => {
it("persists chart and fundamentals data and reuses the local cache", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-"));
const provider: MarketDataProvider = {
snapshot: vi.fn(async () => ({
price: 100,
currency: "USD",
pe: 20,
peers: [],
news: []
})),
candles: vi.fn(async () =>
Array.from({ length: 1010 }, (_, index) => ({
date: new Date(Date.UTC(2022, 0, 3 + index)).toISOString().slice(0, 10),
open: 100 + index,
high: 102 + index,
low: 99 + index,
close: 101 + index,
volume: 1_000_000 + index
}))
),
fundamentals: vi.fn(async () => [
{
period: "2026Q1",
revenue: 1_000_000,
netIncome: 120_000
}
]),
relatedData: vi.fn(async () => ({
sector: "Technology",
avgVolume20: 1_000_000,
volumeRatio: 1.1,
volatility20: 0.02,
drawdownFromHigh: 0.08,
relativeStrength60: 0.05,
support: 98,
resistance: 112,
capitalFlow: 0.1,
sentimentScore: 0.6,
newsHeat: 0.5,
riskFlags: [],
sources: []
})),
quantAnalysis: vi.fn(async () => ({
action: "buy_watch" as const,
score: 72,
confidence: 0.7,
horizon: "1-3 months",
summary: "Positive",
factors: [
{ name: "trend", score: 70, weight: 0.4, evidence: "above average" },
{ name: "valuation", score: 68, weight: 0.3, evidence: "reasonable" },
{ name: "sentiment", score: 74, weight: 0.3, evidence: "supportive" }
],
riskNotes: []
}))
};
const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-11T00:00:00.000Z"));
await cache.chart(stock);
await cache.chart(stock);
await cache.fundamentals(stock);
await cache.fundamentals(stock);
expect(provider.candles).toHaveBeenCalledTimes(1);
expect(provider.fundamentals).toHaveBeenCalledTimes(1);
await expect(cache.snapshot(stock)).resolves.toEqual(expect.objectContaining({ price: 100 }));
await expect(cache.snapshot(stock)).resolves.toEqual(expect.objectContaining({ price: 100 }));
expect(provider.snapshot).toHaveBeenCalledTimes(1);
await expect(cache.chart(stock, "W")).resolves.toEqual(expect.objectContaining({ period: "W" }));
await expect(cache.relatedData(stock)).resolves.toEqual(expect.objectContaining({ data: expect.objectContaining({ sector: "Technology" }) }));
await expect(cache.quantAnalysis(stock)).resolves.toEqual(expect.objectContaining({ analysis: expect.objectContaining({ score: 72 }) }));
await expect(cache.quantAnalysis(stock)).resolves.toEqual(expect.objectContaining({ analysis: expect.objectContaining({ score: 72 }) }));
expect(provider.quantAnalysis).toHaveBeenCalledTimes(1);
});
it("accepts real short listing history instead of requiring a full ten years", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-"));
const provider: MarketDataProvider = {
snapshot: vi.fn(async () => ({ price: 18, currency: "USD", peers: [], news: [] })),
candles: vi.fn(async () =>
Array.from({ length: 225 }, (_, index) => ({
date: new Date(Date.UTC(2025, 5, 18 + index)).toISOString().slice(0, 10),
open: 18 + index / 100,
high: 19 + index / 100,
low: 17 + index / 100,
close: 18.5 + index / 100,
volume: 800_000 + index
}))
),
fundamentals: vi.fn(async () => []),
relatedData: vi.fn(async () => ({ riskFlags: [], sources: [] })),
quantAnalysis: vi.fn(async () => ({
action: "hold" as const,
score: 58,
confidence: 0.6,
horizon: "1-3个月",
summary: "等待确认",
factors: [
{ name: "趋势", score: 58, weight: 0.4, evidence: "短历史可用" },
{ name: "估值", score: 50, weight: 0.3, evidence: "数据不足" },
{ name: "风险", score: 55, weight: 0.3, evidence: "控制仓位" }
],
riskNotes: []
}))
};
const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-12T00:00:00.000Z"));
await expect(cache.chart({ ...stock, id: "SLDE", symbol: "SLDE", display: "SLDE" })).resolves.toEqual(
expect.objectContaining({
candles: expect.arrayContaining([expect.objectContaining({ date: expect.any(String) })])
})
);
expect(provider.candles).toHaveBeenCalledTimes(1);
});
it("caches an empty historical result without treating it as an error", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-"));
const provider: MarketDataProvider = {
snapshot: vi.fn(async () => ({ price: 18, currency: "USD", peers: [], news: [] })),
candles: vi.fn(async () => []),
fundamentals: vi.fn(async () => []),
relatedData: vi.fn(async () => ({ riskFlags: [], sources: [] })),
quantAnalysis: vi.fn(async () => ({
action: "hold" as const,
score: 50,
confidence: 0.4,
horizon: "1-3个月",
summary: "无K线数据,忽略技术面。",
factors: [
{ name: "趋势", score: 50, weight: 0.4, evidence: "无历史数据" },
{ name: "估值", score: 50, weight: 0.3, evidence: "数据不足" },
{ name: "风险", score: 50, weight: 0.3, evidence: "等待数据" }
],
riskNotes: []
}))
};
const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-12T00:00:00.000Z"));
await expect(cache.chart({ ...stock, id: "NEW", symbol: "NEW", display: "NEW" })).resolves.toEqual(
expect.objectContaining({
candles: [],
indicators: []
})
);
await expect(cache.chart({ ...stock, id: "NEW", symbol: "NEW", display: "NEW" })).resolves.toEqual(
expect.objectContaining({
candles: []
})
);
expect(provider.candles).toHaveBeenCalledTimes(1);
});
});

21
web/tests/symbols.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { normalizeSymbol } from "../src/agent/symbols";
describe("normalizeSymbol", () => {
it.each([
["600519", { symbol: "600519", market: "CN", display: "600519.SS" }],
["000001.SZ", { symbol: "000001", market: "CN", display: "000001.SZ" }],
["512480", { symbol: "512480", market: "CN", display: "512480.SS" }],
["512480.SZ", { symbol: "512480", market: "CN", display: "512480.SS" }],
["00700.hk", { symbol: "00700", market: "HK", display: "00700.HK" }],
["AAPL", { symbol: "AAPL", market: "US", display: "AAPL" }],
["msft.us", { symbol: "MSFT", market: "US", display: "MSFT" }]
])("normalizes %s", (input, expected) => {
expect(normalizeSymbol(input)).toEqual(expected);
});
it("rejects empty and unsupported symbols", () => {
expect(() => normalizeSymbol("")).toThrow("Stock symbol is required");
expect(() => normalizeSymbol("!!!")).toThrow("Unsupported stock symbol");
});
});

View File

@@ -0,0 +1,63 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { WatchlistStore } from "../src/agent/watchlistStore";
let tempDir: string | undefined;
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { force: true, recursive: true });
tempDir = undefined;
}
});
describe("WatchlistStore", () => {
it("adds normalized stocks once and persists them", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
const path = join(tempDir, "watchlist.json");
const store = new WatchlistStore(path);
await store.add("aapl", "Apple", ["Core", " AI ", "Core"]);
await store.add("AAPL", "Duplicate");
await store.add("00700.HK", "Tencent");
expect(await store.list()).toEqual([
expect.objectContaining({ display: "AAPL", market: "US", name: "Apple", tags: ["Core", "AI"] }),
expect.objectContaining({ display: "00700.HK", market: "HK", name: "Tencent", tags: [] })
]);
const reloaded = new WatchlistStore(path);
expect(await reloaded.list()).toHaveLength(2);
});
it("removes stocks by any supported symbol form", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
const store = new WatchlistStore(join(tempDir, "watchlist.json"));
await store.add("600519", "Kweichow Moutai");
await store.remove("600519.SS");
expect(await store.list()).toEqual([]);
});
it("updates tags for a persisted stock", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
const store = new WatchlistStore(join(tempDir, "watchlist.json"));
await store.add("MSFT", "Microsoft");
await store.updateTags("MSFT.US", ["cloud", "quality"]);
expect((await store.list())[0]).toEqual(expect.objectContaining({ tags: ["cloud", "quality"] }));
});
it("canonicalizes persisted China ETF exchange suffixes", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
const store = new WatchlistStore(join(tempDir, "watchlist.json"));
await store.add("512480.SZ", "Semiconductor ETF");
expect((await store.list())[0]).toEqual(expect.objectContaining({ display: "512480.SS", id: "512480.SS" }));
});
});

20
web/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "tests", "vite.config.ts"]
}

18
web/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const host = process.env.HOST ?? "127.0.0.1";
const clientPort = Number(process.env.CLIENT_PORT ?? 5173);
const apiPort = Number(process.env.PORT ?? 8787);
export default defineConfig({
plugins: [react()],
server: {
host,
port: clientPort,
allowedHosts: ["gahow-pc.ipads-lab.se.sjtu.edu.cn"],
proxy: {
"/api": `http://127.0.0.1:${apiPort}`
}
}
});