feat: Add Phase 4 advanced metrics and components
- Add advanced metrics dashboard with trade analytics - Add new trading components (EntryTypeAnalysis, MultiDayPositionTracker, NewsEventTracker, etc.) - Add strategy mode selector and trend confirmation - Add risk automation panel and slippage correlation analysis - Add daily trading plan enhancements with modal components - Add custom hooks (useApi, useLocalStorage, useAdvancedTradeMetrics) - Add broker service integration and trading API - Add test setup and vitest configuration - Include parquet data files for live market data - Add comprehensive documentation in docs/ folder
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.metals.bullionvault_service import BullionVaultService
|
||||
|
||||
|
||||
def _csv_response(rows: list[str]) -> str:
|
||||
header = (
|
||||
'"Date","High (kg)","Low (kg)","Close (kg)",,"High (troy oz)","Low (troy oz)","Close (troy oz)",\n'
|
||||
)
|
||||
return header + "".join(f"{row}\n" for row in rows)
|
||||
|
||||
|
||||
def _mock_client(handler):
|
||||
transport = httpx.MockTransport(handler)
|
||||
return httpx.AsyncClient(transport=transport)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_gold_price_parses_csv() -> None:
|
||||
csv = _csv_response(
|
||||
[
|
||||
'"12:00:00 23-Nov-2025",130702.99,130600.11,130650.10,,4065.32,4040.32,4050.55,',
|
||||
'"11:50:00 23-Nov-2025",130600.99,130500.11,130550.10,,4055.32,4030.32,4040.55,',
|
||||
]
|
||||
)
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text=csv)
|
||||
|
||||
async with _mock_client(handler) as client:
|
||||
service = BullionVaultService(client=client, base_url="https://chart-data.bullionvault.com")
|
||||
result = await service.get_current_gold_price("usd")
|
||||
|
||||
assert result["currency"] == "USD"
|
||||
assert result["price"] == pytest.approx(4050.55, rel=0.001)
|
||||
assert result["price_kg"] == pytest.approx(130650.10, rel=0.001)
|
||||
assert result["data_points"] == 2
|
||||
assert datetime.fromisoformat(result["timestamp"]).tzinfo == timezone.utc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_gold_price_raises_on_empty_data() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text='"Date",\n')
|
||||
|
||||
async with _mock_client(handler) as client:
|
||||
service = BullionVaultService(client=client)
|
||||
|
||||
with pytest.raises(ValueError, match="No price data available"):
|
||||
await service.get_current_gold_price()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gold_history_applies_limit() -> None:
|
||||
csv = _csv_response(
|
||||
[
|
||||
'"12:00:00 23-Nov-2025",130702.99,130600.11,130650.10,,4065.32,4040.32,4050.55,',
|
||||
'"11:50:00 23-Nov-2025",130600.99,130500.11,130550.10,,4055.32,4030.32,4040.55,',
|
||||
'"11:40:00 23-Nov-2025",130500.99,130400.11,130450.10,,4045.32,4020.32,4030.55,',
|
||||
]
|
||||
)
|
||||
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text=csv)
|
||||
|
||||
async with _mock_client(handler) as client:
|
||||
service = BullionVaultService(client=client)
|
||||
history = await service.get_gold_history(limit=2)
|
||||
|
||||
assert len(history) == 2
|
||||
assert history[0]["close"] == pytest.approx(4050.55, rel=0.001)
|
||||
assert history[1]["close"] == pytest.approx(4040.55, rel=0.001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_currency_prices_skips_failures() -> None:
|
||||
csv = _csv_response(
|
||||
[
|
||||
'"12:00:00 23-Nov-2025",130702.99,130600.11,130650.10,,4065.32,4040.32,4050.55,',
|
||||
]
|
||||
)
|
||||
|
||||
retry_counts: dict[str, int] = {}
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
currency = request.url.path.split("/")[4]
|
||||
retry_counts[currency] = retry_counts.get(currency, 0) + 1
|
||||
|
||||
if currency == "GBP":
|
||||
return httpx.Response(500, text="error")
|
||||
|
||||
return httpx.Response(200, text=csv)
|
||||
|
||||
async with _mock_client(handler) as client:
|
||||
service = BullionVaultService(client=client, max_retries=1)
|
||||
prices = await service.get_multi_currency_prices()
|
||||
|
||||
# Only currencies served by successful responses should be included
|
||||
assert "USD" in prices
|
||||
assert "EUR" in prices
|
||||
assert "GBP" not in prices
|
||||
assert retry_counts["GBP"] == 1
|
||||
@@ -0,0 +1,90 @@
|
||||
import math
|
||||
|
||||
from app.schemas.schemas import PriceData
|
||||
from app.services.ai_context_builder import AIContextBuilder
|
||||
|
||||
|
||||
def _build_sample_price_data(bars: int = 220) -> list[PriceData]:
|
||||
base_price = 1950.0
|
||||
price_data: list[PriceData] = []
|
||||
for i in range(bars):
|
||||
drift = i * 0.25
|
||||
wave = math.sin(i / 7.0) * 3.0
|
||||
close = base_price + drift + wave
|
||||
high = close + 0.8
|
||||
low = close - 0.8
|
||||
open_price = close - math.sin(i / 11.0) * 0.5
|
||||
price_data.append(
|
||||
PriceData(
|
||||
time=i,
|
||||
open=open_price,
|
||||
high=high,
|
||||
low=low,
|
||||
close=close,
|
||||
volume=1000 + i,
|
||||
)
|
||||
)
|
||||
return price_data
|
||||
|
||||
|
||||
def test_build_metrics_includes_enhanced_indicators():
|
||||
price_data = _build_sample_price_data()
|
||||
builder = AIContextBuilder()
|
||||
|
||||
metrics = builder.build_metrics("XAUUSD", "1m", price_data)
|
||||
|
||||
assert metrics.bb_basis is not None
|
||||
assert metrics.bb_upper is not None
|
||||
assert metrics.bb_lower is not None
|
||||
assert metrics.rsi3 is not None
|
||||
assert metrics.zlsma is not None
|
||||
assert metrics.chandelier_long_stop is not None
|
||||
assert metrics.chandelier_short_stop is not None
|
||||
assert metrics.chandelier_signal in {None, "LONG", "SHORT", "NEUTRAL"}
|
||||
assert metrics.bb_signal in {None, "LONG", "SHORT"}
|
||||
|
||||
|
||||
def test_indicator_payload_emits_enhanced_metrics():
|
||||
price_data = _build_sample_price_data()
|
||||
builder = AIContextBuilder()
|
||||
bars = [
|
||||
{
|
||||
"time": item.time,
|
||||
"open": item.open,
|
||||
"high": item.high,
|
||||
"low": item.low,
|
||||
"close": item.close,
|
||||
"volume": item.volume,
|
||||
}
|
||||
for item in price_data
|
||||
]
|
||||
|
||||
indicator_payload = builder._build_indicators(bars) # type: ignore[attr-defined]
|
||||
indicator_names = {entry["name"] for entry in indicator_payload}
|
||||
|
||||
assert "RSI_3" in indicator_names
|
||||
assert "BB_20_BASIS" in indicator_names
|
||||
assert "BB_20_UPPER" in indicator_names
|
||||
assert "BB_20_LOWER" in indicator_names
|
||||
assert "ZLSMA_50" in indicator_names
|
||||
assert "CHAND_22_LONG" in indicator_names
|
||||
assert "CHAND_22_SHORT" in indicator_names
|
||||
|
||||
|
||||
def test_candlestick_patterns_detected():
|
||||
candles = [
|
||||
PriceData(time=0, open=100.0, high=101.0, low=99.0, close=99.0, volume=1000),
|
||||
PriceData(time=1, open=99.2, high=100.0, low=95.2, close=95.5, volume=1005),
|
||||
PriceData(time=2, open=95.0, high=101.2, low=94.8, close=100.8, volume=1010), # Bullish engulfing candle
|
||||
PriceData(time=3, open=100.1, high=100.4, low=99.9, close=100.12, volume=1015), # Doji
|
||||
PriceData(time=4, open=99.8, high=100.2, low=95.5, close=99.7, volume=1020), # Long lower shadow
|
||||
]
|
||||
|
||||
builder = AIContextBuilder()
|
||||
metrics = builder.build_metrics("XAUUSD", "1m", candles)
|
||||
|
||||
names = {signal.pattern for signal in metrics.pattern_signals}
|
||||
|
||||
assert "Bullish Engulfing" in names
|
||||
assert "Doji" in names
|
||||
assert "Long Lower Shadow" in names
|
||||
Reference in New Issue
Block a user