Compare commits
20 Commits
0d983edfc0
...
83495bcd23
| Author | SHA1 | Date | |
|---|---|---|---|
| 83495bcd23 | |||
| 96a845ebcd | |||
| 0eb5b95efa | |||
| f6b46d4d68 | |||
| 6dc623b4a1 | |||
| 69636c3fae | |||
| 10a74ea4f7 | |||
| 592de02e27 | |||
| 0d3642435b | |||
| eefd879a93 | |||
| 1697bc1641 | |||
| 11f4dd95dd | |||
| 838749d8b2 | |||
| c2882ae4cf | |||
| f0739185d4 | |||
| 5716267309 | |||
| f54041646f | |||
| a4a2cb2ccf | |||
| 30f6cd84a8 | |||
| c87f37974e |
5
.env.example
Normal file
5
.env.example
Normal 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
15
.gitignore
vendored
@@ -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/
|
||||
|
||||
55
CLAUDE.md
55
CLAUDE.md
@@ -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
324
bridge.py
@@ -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
|
||||
|
||||
415
data_manager.py
415
data_manager.py
@@ -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
23
deploy/start-monitor.sh
Executable 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
14
deploy/start-web-api.sh
Executable 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
11
deploy/start-web-vite.sh
Executable 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
|
||||
14
deploy/systemd/quant-trading-api.service
Normal file
14
deploy/systemd/quant-trading-api.service
Normal 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
|
||||
14
deploy/systemd/quant-trading-monitor.service
Normal file
14
deploy/systemd/quant-trading-monitor.service
Normal 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
|
||||
13
deploy/systemd/quant-trading-vite.service
Normal file
13
deploy/systemd/quant-trading-vite.service
Normal 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
26
main.py
@@ -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
|
||||
|
||||
|
||||
|
||||
12
metrics.py
12
metrics.py
@@ -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
419
precompute_backtest.py
Normal 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()
|
||||
@@ -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
116
scripts/run_daily.py
Normal 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
21
scripts/run_daily.sh
Executable 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
128
tests/test_live_pipeline.py
Normal 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
598
trader.py
@@ -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":
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
2
uv.lock
generated
@@ -761,7 +761,7 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quant"
|
||||
name = "quant-trading"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
|
||||
7
web/.gitignore
vendored
Normal file
7
web/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
.qoder/
|
||||
68
web/README.md
Normal file
68
web/README.md
Normal 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
12
web/index.html
Normal 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
3491
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
web/package.json
Normal file
36
web/package.json
Normal 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
119
web/src/agent/indicators.ts
Normal 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
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
144
web/src/agent/report.ts
Normal 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;
|
||||
}
|
||||
380
web/src/agent/stockDataCache.ts
Normal file
380
web/src/agent/stockDataCache.ts
Normal 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
52
web/src/agent/symbols.ts
Normal 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" ? "港股" : "美股";
|
||||
}
|
||||
96
web/src/agent/watchlistStore.ts
Normal file
96
web/src/agent/watchlistStore.ts
Normal 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
686
web/src/client/LivePage.tsx
Normal 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;
|
||||
984
web/src/client/StrategyPage.tsx
Normal file
984
web/src/client/StrategyPage.tsx
Normal 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
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
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
351
web/src/server/index.ts
Normal 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";
|
||||
}
|
||||
18
web/src/server/ipadsConfig.ts
Normal file
18
web/src/server/ipadsConfig.ts
Normal 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
14
web/src/server/paths.ts
Normal 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");
|
||||
178
web/src/server/strategyService.ts
Normal file
178
web/src/server/strategyService.ts
Normal 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 ?? []
|
||||
}
|
||||
};
|
||||
}
|
||||
232
web/src/shared/strategyTypes.ts
Normal file
232
web/src/shared/strategyTypes.ts
Normal 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
238
web/src/shared/types.ts
Normal 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
217
web/tests/provider.test.ts
Normal 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
116
web/tests/report.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
179
web/tests/stockDataCache.test.ts
Normal file
179
web/tests/stockDataCache.test.ts
Normal 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
21
web/tests/symbols.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
63
web/tests/watchlistStore.test.ts
Normal file
63
web/tests/watchlistStore.test.ts
Normal 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
20
web/tsconfig.json
Normal 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
18
web/vite.config.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user