Skip to content
120 changes: 120 additions & 0 deletions docs/superpowers/specs/2026-04-07-multi-timeframe-analysis-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Multi-Timeframe Technical Analysis

## Context

The current `analyze technical` command only operates on daily data, limiting the user's ability to perceive medium-to-long-term trends. Weekly and monthly timeframe indicators provide essential context for understanding whether a stock is in an uptrend, downtrend, or consolidation at a macro level. This feature adds weekly/monthly technical indicator output to complement the existing daily analysis — the CLI provides data only; qualitative interpretation is left to the agent.

## Design Principle

**CLI = data provider, Agent = interpreter.** The CLI outputs numerical indicator values for each timeframe without making qualitative judgments (no "bullish"/"bearish" labels). The Claude skill layer interprets the data.

## Approach: Computation-Layer Aggregation

Aggregate existing daily price bars into weekly/monthly bars at computation time. No changes to `PriceBar` model, database schema, or market providers.

**Why not API-based weekly/monthly data?**
- Aggregated results are mathematically identical for complete periods
- CoinGecko doesn't support weekly/monthly K-lines, requiring a fallback anyway
- Zero additional API calls; all markets handled uniformly

## Data Aggregation

### New file: `haoinvest/engine/aggregation.py`

Two public functions:

```python
def aggregate_to_weekly(daily_bars: list[PriceBar]) -> list[PriceBar]
def aggregate_to_monthly(daily_bars: list[PriceBar]) -> list[PriceBar]
```

**Aggregation rules per period (week/month):**

| Field | Rule |
|-------|------|
| `open` | First trading day's open |
| `high` | max(all highs) |
| `low` | min(all lows) |
| `close` | Last trading day's close |
| `volume` | sum(all volumes) |
| `trade_date` | Last trading day's date |
| `symbol` | Inherited from source bars |
| `market_type` | Inherited from source bars |

**Week boundary:** ISO week (Monday–Sunday).
**Month boundary:** Calendar month.

**Handling incomplete periods:** The current (most recent) week/month may be incomplete. Include it as-is — the agent can account for this.

### Data period requirement

To compute monthly MA20 + MACD(26), need ~26 months of monthly data → ~2 years of daily data. The `analyze technical` default fetch period expands from 1 year to 2 years.

## Technical Indicator Calculation

Reuse existing `compute_technical()` from `haoinvest/engine/technical_engine.py`. It accepts a DataFrame and is timeframe-agnostic.

**Indicators computed per timeframe (weekly & monthly):**
- MA: SMA(5, 10, 20), EMA(12, 26)
- MACD: DIF, DEA, histogram (fast=12, slow=26, signal=9)
- RSI(14)

Bollinger Bands are excluded from weekly/monthly to keep output concise.

**Call flow:**
1. Fetch 2 years of daily bars
2. `aggregate_to_weekly(bars)` → weekly bars → `pricebars_to_dataframe()` → `compute_technical()` → weekly indicators
3. `aggregate_to_monthly(bars)` → monthly bars → `pricebars_to_dataframe()` → `compute_technical()` → monthly indicators
4. Daily bars → existing flow unchanged

## Output Structure

### Text output (default)

Layered from macro to micro:

```
📊 月线技术指标 (Monthly)
MA: MA5=xx.xx MA10=xx.xx MA20=xx.xx | EMA12=xx.xx EMA26=xx.xx
MACD: DIF=xx.xx DEA=xx.xx MACD=xx.xx
RSI(14): xx.xx

📊 周线技术指标 (Weekly)
MA: MA5=xx.xx MA10=xx.xx MA20=xx.xx | EMA12=xx.xx EMA26=xx.xx
MACD: DIF=xx.xx DEA=xx.xx MACD=xx.xx
RSI(14): xx.xx

📊 日线技术指标 (Daily)
[existing output unchanged]
```

### JSON output (`--format json`)

Add `weekly` and `monthly` keys at the same level as the existing daily indicators:

```json
{
"monthly": { "ma": {...}, "macd": {...}, "rsi": ... },
"weekly": { "ma": {...}, "macd": {...}, "rsi": ... },
"daily": { ... }
}
```

## Files to Modify

| File | Change |
|------|--------|
| `haoinvest/engine/aggregation.py` | **New** — weekly/monthly aggregation functions |
| `haoinvest/analysis/technical.py` | Add multi-timeframe analysis entry point |
| `haoinvest/cli/analyze.py` | Expand `technical` command to output weekly/monthly layers |
| `haoinvest/cli/formatters.py` | Format weekly/monthly indicator output |
| `haoinvest/models.py` | Add `timeframe` field to `TechnicalIndicators` (optional) |
| `tests/test_aggregation.py` | **New** — unit tests for aggregation logic |
| `tests/test_technical.py` | Add multi-timeframe analysis tests |

## Verification

1. **Unit tests:** Test aggregation with known daily data → verify weekly/monthly OHLCV values
2. **Integration test:** Run `haoinvest analyze technical 600519` → verify monthly/weekly/daily sections all appear
3. **JSON output:** Run with `--format json` → verify `monthly` and `weekly` keys present with correct structure
4. **Edge cases:** Test with < 1 month of data (monthly section should gracefully show what's available or indicate insufficient data)
3 changes: 2 additions & 1 deletion haoinvest/analysis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
from .fundamental import analyze_stock
from .risk import calculate_risk_metrics, portfolio_correlation
from .signals import aggregate_signals
from .technical import analyze_technical
from .technical import analyze_technical, analyze_technical_multi
from .volume import analyze_volume

__all__ = [
"aggregate_signals",
"analyze_stock",
"analyze_technical",
"analyze_technical_multi",
"analyze_volume",
"calculate_risk_metrics",
"portfolio_correlation",
Expand Down
43 changes: 42 additions & 1 deletion haoinvest/analysis/technical.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from datetime import date

from ..db import Database
from ..engine.aggregation import aggregate_to_monthly, aggregate_to_weekly
from ..engine.databridge import pricebars_to_dataframe
from ..engine.technical_engine import compute_technical
from ..models import MarketType, TechnicalIndicators
from ..models import MarketType, MultiTimeframeTechnical, TechnicalIndicators


def analyze_technical(
Expand Down Expand Up @@ -37,3 +38,43 @@ def analyze_technical(
result.symbol = symbol
result.market_type = mt_str
return result


def analyze_technical_multi(
db: Database,
symbol: str,
market_type: MarketType,
start_date: date | None = None,
end_date: date | None = None,
verbose: bool = False,
) -> MultiTimeframeTechnical:
"""Calculate technical indicators across daily, weekly, and monthly timeframes."""
bars = db.get_prices(symbol, market_type, start_date, end_date)
mt_str = market_type.value

def _compute_for_bars(bar_list: list, timeframe: str) -> TechnicalIndicators:
df = pricebars_to_dataframe(bar_list)
if len(df) < 14:
return TechnicalIndicators(
symbol=symbol,
market_type=mt_str,
timeframe=timeframe,
message=f"Not enough {timeframe} data ({len(df)} bars, need at least 14)",
)
result = compute_technical(df, verbose=verbose)
result.symbol = symbol
result.market_type = mt_str
result.timeframe = timeframe
return result

daily = _compute_for_bars(bars, "daily")
weekly = _compute_for_bars(aggregate_to_weekly(bars), "weekly")
monthly = _compute_for_bars(aggregate_to_monthly(bars), "monthly")

return MultiTimeframeTechnical(
symbol=symbol,
market_type=mt_str,
daily=daily,
weekly=weekly,
monthly=monthly,
)
38 changes: 31 additions & 7 deletions haoinvest/cli/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@
from ..analysis.fundamental import analyze_stock
from ..analysis.risk import calculate_risk_metrics, portfolio_correlation
from ..analysis.signals import aggregate_signals
from ..analysis.technical import analyze_technical
from ..analysis.technical import analyze_technical, analyze_technical_multi
from ..analysis.volume import analyze_volume
from ..db import Database
from ..models import MarketType
from .formatters import error_output, json_output, kv_output, tsv_output
from .formatters import (
error_output,
json_output,
kv_output,
timeframe_section,
tsv_output,
)
from .market import _detect_market_type

app = typer.Typer(help="Analysis — fundamental, risk, technical, volume, signals.")
Expand All @@ -32,6 +38,15 @@ def _ensure_prices_cached(

existing = db.get_prices(symbol, market_type, start, end)
if len(existing) > 10:
# Check if cached data covers the requested start date.
# If not, fetch the missing earlier portion.
earliest_cached = min(b.trade_date for b in existing)
if earliest_cached <= start + timedelta(days=7):
return
provider = get_provider(market_type)
bars = provider.get_price_history(symbol, start, earliest_cached)
if bars:
db.save_prices(bars)
return
provider = get_provider(market_type)
bars = provider.get_price_history(symbol, start, end)
Expand Down Expand Up @@ -254,23 +269,32 @@ def technical(
),
use_json: bool = typer.Option(False, "--json", help="Output as JSON"),
) -> None:
"""Technical indicators — MA, MACD, RSI, Bollinger Bands."""
"""Technical indicators — MA, MACD, RSI, Bollinger Bands (daily/weekly/monthly)."""
db = _init_db()
end_date = date.fromisoformat(end) if end else date.today()
start_date = date.fromisoformat(start) if start else end_date - timedelta(days=365)
start_date = date.fromisoformat(start) if start else end_date - timedelta(days=1095)
symbol_list = [s.strip() for s in symbol.split(",")]

if len(symbol_list) == 1:
# Single symbol — detailed kv_output (original behavior)
# Single symbol — multi-timeframe output
mt = MarketType(market_type) if market_type else _detect_market_type(symbol)
_ensure_prices_cached(db, symbol, mt, start_date, end_date)
result = analyze_technical(
multi = analyze_technical_multi(
db, symbol, mt, start_date, end_date, verbose=verbose
)

if use_json:
json_output(result)
json_output(multi)
else:
# Monthly and weekly: compact timeframe sections
if multi.monthly:
timeframe_section("月线技术指标 (Monthly)", multi.monthly, verbose)
if multi.weekly:
timeframe_section("周线技术指标 (Weekly)", multi.weekly, verbose)

# Daily: preserve original kv_output format for backward compatibility
result = multi.daily
print() # blank line before daily section
output = {"symbol": result.symbol, "date": str(result.latest_date)}
if result.message:
output["message"] = result.message
Expand Down
28 changes: 28 additions & 0 deletions haoinvest/cli/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ def json_output(data: Any) -> None:
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))


def timeframe_section(
label: str,
result: Any,
verbose: bool = False,
) -> None:
"""Print a compact technical indicator section for a single timeframe."""
print(f"\n📊 {label}")
if result.message:
print(f" {result.message}")
return
ma = result.moving_averages
print(
f" MA: SMA5={ma.sma_5} SMA10={ma.sma_10} SMA20={ma.sma_20}"
f" | EMA12={ma.ema_12} EMA26={ma.ema_26}"
)
if verbose and ma.explanation:
print(f" MA说明: {ma.explanation}")
macd = result.macd
print(
f" MACD: DIF={macd.macd_line} DEA={macd.signal_line} MACD={macd.histogram}"
)
if verbose and macd.explanation:
print(f" MACD说明: {macd.explanation}")
print(f" RSI(14): {result.rsi.rsi}")
if verbose and result.rsi.explanation:
print(f" RSI说明: {result.rsi.explanation}")


def error_output(message: str) -> None:
"""Print error message to stderr."""
print(f"Error: {message}", file=sys.stderr)
61 changes: 61 additions & 0 deletions haoinvest/engine/aggregation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Aggregate daily price bars into weekly/monthly bars."""

from collections import defaultdict
from datetime import date
from typing import Callable

from ..models import PriceBar


def aggregate_to_weekly(daily_bars: list[PriceBar]) -> list[PriceBar]:
"""Aggregate daily bars into weekly bars (ISO week: Monday–Sunday)."""
return _aggregate_bars(
daily_bars,
group_key=lambda d: d.isocalendar()[:2], # (iso_year, iso_week)
)


def aggregate_to_monthly(daily_bars: list[PriceBar]) -> list[PriceBar]:
"""Aggregate daily bars into monthly bars (calendar month)."""
return _aggregate_bars(
daily_bars,
group_key=lambda d: (d.year, d.month),
)


def _aggregate_bars(
bars: list[PriceBar],
group_key: Callable[[date], tuple],
) -> list[PriceBar]:
"""Group daily bars by key and aggregate OHLCV per group.

Sorts input by trade_date first so open/close pick the correct first/last bar.
"""
if not bars:
return []

sorted_bars = sorted(bars, key=lambda b: b.trade_date)
groups: dict[tuple, list[PriceBar]] = defaultdict(list)
for bar in sorted_bars:
groups[group_key(bar.trade_date)].append(bar)

result: list[PriceBar] = []
for group_bars in groups.values():
highs = [b.high for b in group_bars if b.high is not None]
lows = [b.low for b in group_bars if b.low is not None]
volumes = [b.volume for b in group_bars if b.volume is not None]

result.append(
PriceBar(
symbol=group_bars[0].symbol,
market_type=group_bars[0].market_type,
trade_date=group_bars[-1].trade_date,
open=group_bars[0].open,
high=max(highs) if highs else None,
low=min(lows) if lows else None,
close=group_bars[-1].close,
volume=sum(volumes) if volumes else None,
)
)

return sorted(result, key=lambda b: b.trade_date)
11 changes: 11 additions & 0 deletions haoinvest/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,11 +377,22 @@ class TechnicalIndicators(BaseModel):
macd: MACDResult = Field(default_factory=MACDResult)
rsi: RSIResult = Field(default_factory=RSIResult)
bollinger: BollingerBands = Field(default_factory=BollingerBands)
timeframe: str = Field(default="daily", description="daily/weekly/monthly")
message: Optional[str] = Field(
default=None, description="Warning if insufficient data"
)


class MultiTimeframeTechnical(BaseModel):
"""Multi-timeframe technical analysis result."""

symbol: str
market_type: str
daily: TechnicalIndicators
weekly: Optional[TechnicalIndicators] = None
monthly: Optional[TechnicalIndicators] = None


class VolumeAnalysis(BaseModel):
"""Volume anomaly and turnover analysis."""

Expand Down
Loading
Loading