ci: fix lint baseline and stabilize failing main tests
Some checks failed
Gitea CI / test (push) Failing after 5s
Gitea CI / test (pull_request) Failing after 5s

This commit is contained in:
agentson
2026-03-01 20:17:13 +09:00
parent 6f047a6daf
commit 5730f0db2a
64 changed files with 1041 additions and 1380 deletions

View File

@@ -2,8 +2,8 @@
from __future__ import annotations
from dataclasses import dataclass
import math
from dataclasses import dataclass
@dataclass(frozen=True)

View File

@@ -2,12 +2,11 @@
from __future__ import annotations
from dataclasses import dataclass
import math
from dataclasses import dataclass
from random import Random
from typing import Literal
OrderSide = Literal["BUY", "SELL"]
@@ -77,7 +76,9 @@ class BacktestExecutionModel:
reason="execution_failure",
)
slip_mult = 1.0 + (slippage_bps / 10000.0 if request.side == "BUY" else -slippage_bps / 10000.0)
slip_mult = 1.0 + (
slippage_bps / 10000.0 if request.side == "BUY" else -slippage_bps / 10000.0
)
exec_price = request.reference_price * slip_mult
if self._rng.random() < partial_rate:

View File

@@ -10,8 +10,7 @@ from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from statistics import mean
from typing import Literal
from typing import cast
from typing import Literal, cast
from src.analysis.backtest_cost_guard import BacktestCostModel, validate_backtest_cost_model
from src.analysis.triple_barrier import TripleBarrierSpec, label_with_triple_barrier

View File

@@ -104,6 +104,7 @@ class MarketScanner:
# Store in L7 real-time layer
from datetime import UTC, datetime
timeframe = datetime.now(UTC).isoformat()
self.context_store.set_context(
ContextLayer.L7_REALTIME,
@@ -158,12 +159,8 @@ class MarketScanner:
top_movers = valid_metrics[: self.top_n]
# Detect breakouts and breakdowns
breakouts = [
m.stock_code for m in valid_metrics if self.analyzer.is_breakout(m)
]
breakdowns = [
m.stock_code for m in valid_metrics if self.analyzer.is_breakdown(m)
]
breakouts = [m.stock_code for m in valid_metrics if self.analyzer.is_breakout(m)]
breakdowns = [m.stock_code for m in valid_metrics if self.analyzer.is_breakdown(m)]
logger.info(
"%s scan complete: %d scanned, top momentum=%.1f, %d breakouts, %d breakdowns",
@@ -228,10 +225,9 @@ class MarketScanner:
# If we removed too many, backfill from current watchlist
if len(updated) < len(current_watchlist):
backfill = [
code for code in current_watchlist
if code not in updated
][: len(current_watchlist) - len(updated)]
backfill = [code for code in current_watchlist if code not in updated][
: len(current_watchlist) - len(updated)
]
updated.extend(backfill)
logger.info(

View File

@@ -158,7 +158,12 @@ class SmartVolatilityScanner:
price = latest_close
latest_high = _safe_float(latest.get("high"))
latest_low = _safe_float(latest.get("low"))
if latest_close > 0 and latest_high > 0 and latest_low > 0 and latest_high >= latest_low:
if (
latest_close > 0
and latest_high > 0
and latest_low > 0
and latest_high >= latest_low
):
intraday_range_pct = (latest_high - latest_low) / latest_close * 100.0
if volume <= 0:
volume = _safe_float(latest.get("volume"))
@@ -234,9 +239,7 @@ class SmartVolatilityScanner:
limit=50,
)
except Exception as exc:
logger.warning(
"Overseas fluctuation ranking failed for %s: %s", market.code, exc
)
logger.warning("Overseas fluctuation ranking failed for %s: %s", market.code, exc)
fluct_rows = []
if not fluct_rows:
@@ -250,9 +253,7 @@ class SmartVolatilityScanner:
limit=50,
)
except Exception as exc:
logger.warning(
"Overseas volume ranking failed for %s: %s", market.code, exc
)
logger.warning("Overseas volume ranking failed for %s: %s", market.code, exc)
volume_rows = []
for idx, row in enumerate(volume_rows):
@@ -433,16 +434,10 @@ def _extract_intraday_range_pct(row: dict[str, Any], price: float) -> float:
if price <= 0:
return 0.0
high = _safe_float(
row.get("high")
or row.get("ovrs_hgpr")
or row.get("stck_hgpr")
or row.get("day_hgpr")
row.get("high") or row.get("ovrs_hgpr") or row.get("stck_hgpr") or row.get("day_hgpr")
)
low = _safe_float(
row.get("low")
or row.get("ovrs_lwpr")
or row.get("stck_lwpr")
or row.get("day_lwpr")
row.get("low") or row.get("ovrs_lwpr") or row.get("stck_lwpr") or row.get("day_lwpr")
)
if high <= 0 or low <= 0 or high < low:
return 0.0

View File

@@ -6,10 +6,10 @@ Implements first-touch labeling with upper/lower/time barriers.
from __future__ import annotations
import warnings
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Literal, Sequence
from typing import Literal
TieBreakMode = Literal["stop_first", "take_first"]
@@ -92,7 +92,10 @@ def label_with_triple_barrier(
else:
assert spec.max_holding_bars is not None
warnings.warn(
"TripleBarrierSpec.max_holding_bars is deprecated; use max_holding_minutes with timestamps instead.",
(
"TripleBarrierSpec.max_holding_bars is deprecated; "
"use max_holding_minutes with timestamps instead."
),
DeprecationWarning,
stacklevel=2,
)

View File

@@ -92,9 +92,7 @@ class VolatilityAnalyzer:
recent_tr = true_ranges[-period:]
return sum(recent_tr) / len(recent_tr)
def calculate_price_change(
self, current_price: float, past_price: float
) -> float:
def calculate_price_change(self, current_price: float, past_price: float) -> float:
"""Calculate price change percentage.
Args:
@@ -108,9 +106,7 @@ class VolatilityAnalyzer:
return 0.0
return ((current_price - past_price) / past_price) * 100
def calculate_volume_surge(
self, current_volume: float, avg_volume: float
) -> float:
def calculate_volume_surge(self, current_volume: float, avg_volume: float) -> float:
"""Calculate volume surge ratio.
Args:
@@ -240,11 +236,7 @@ class VolatilityAnalyzer:
Momentum score (0-100)
"""
# Weight recent changes more heavily
weighted_change = (
price_change_1m * 0.4 +
price_change_5m * 0.3 +
price_change_15m * 0.2
)
weighted_change = price_change_1m * 0.4 + price_change_5m * 0.3 + price_change_15m * 0.2
# Volume contribution (normalized to 0-10 scale)
volume_contribution = min(10.0, (volume_surge - 1.0) * 5.0)
@@ -301,17 +293,11 @@ class VolatilityAnalyzer:
if len(close_prices) > 0:
if len(close_prices) >= 1:
price_change_1m = self.calculate_price_change(
current_price, close_prices[-1]
)
price_change_1m = self.calculate_price_change(current_price, close_prices[-1])
if len(close_prices) >= 5:
price_change_5m = self.calculate_price_change(
current_price, close_prices[-5]
)
price_change_5m = self.calculate_price_change(current_price, close_prices[-5])
if len(close_prices) >= 15:
price_change_15m = self.calculate_price_change(
current_price, close_prices[-15]
)
price_change_15m = self.calculate_price_change(current_price, close_prices[-15])
# Calculate volume surge
avg_volume = sum(volumes) / len(volumes) if volumes else current_volume

View File

@@ -7,9 +7,9 @@ This module provides:
- Health monitoring and alerts
"""
from src.backup.exporter import BackupExporter, ExportFormat
from src.backup.scheduler import BackupScheduler, BackupPolicy
from src.backup.cloud_storage import CloudStorage, S3Config
from src.backup.exporter import BackupExporter, ExportFormat
from src.backup.scheduler import BackupPolicy, BackupScheduler
__all__ = [
"BackupExporter",

View File

@@ -94,7 +94,9 @@ class CloudStorage:
if metadata:
extra_args["Metadata"] = metadata
logger.info("Uploading %s to s3://%s/%s", file_path.name, self.config.bucket_name, object_key)
logger.info(
"Uploading %s to s3://%s/%s", file_path.name, self.config.bucket_name, object_key
)
try:
self.client.upload_file(

View File

@@ -14,14 +14,14 @@ import json
import logging
import sqlite3
from datetime import UTC, datetime
from enum import Enum
from enum import StrEnum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class ExportFormat(str, Enum):
class ExportFormat(StrEnum):
"""Supported export formats."""
JSON = "json"
@@ -103,15 +103,11 @@ class BackupExporter:
elif fmt == ExportFormat.CSV:
return self._export_csv(output_dir, timestamp, compress, incremental_since)
elif fmt == ExportFormat.PARQUET:
return self._export_parquet(
output_dir, timestamp, compress, incremental_since
)
return self._export_parquet(output_dir, timestamp, compress, incremental_since)
else:
raise ValueError(f"Unsupported format: {fmt}")
def _get_trades(
self, incremental_since: datetime | None = None
) -> list[dict[str, Any]]:
def _get_trades(self, incremental_since: datetime | None = None) -> list[dict[str, Any]]:
"""Fetch trades from database.
Args:
@@ -164,9 +160,7 @@ class BackupExporter:
data = {
"export_timestamp": datetime.now(UTC).isoformat(),
"incremental_since": (
incremental_since.isoformat() if incremental_since else None
),
"incremental_since": (incremental_since.isoformat() if incremental_since else None),
"record_count": len(trades),
"trades": trades,
}
@@ -284,8 +278,7 @@ class BackupExporter:
import pyarrow.parquet as pq
except ImportError:
raise ImportError(
"pyarrow is required for Parquet export. "
"Install with: pip install pyarrow"
"pyarrow is required for Parquet export. Install with: pip install pyarrow"
)
# Convert to pyarrow table

View File

@@ -14,14 +14,14 @@ import shutil
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import Enum
from enum import StrEnum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class HealthStatus(str, Enum):
class HealthStatus(StrEnum):
"""Health check status."""
HEALTHY = "healthy"
@@ -137,9 +137,13 @@ class HealthMonitor:
used_percent = (stat.used / stat.total) * 100
if stat.free < self.min_disk_space_bytes:
min_disk_gb = self.min_disk_space_bytes / 1024 / 1024 / 1024
return HealthCheckResult(
status=HealthStatus.UNHEALTHY,
message=f"Low disk space: {free_gb:.2f} GB free (minimum: {self.min_disk_space_bytes / 1024 / 1024 / 1024:.2f} GB)",
message=(
f"Low disk space: {free_gb:.2f} GB free "
f"(minimum: {min_disk_gb:.2f} GB)"
),
details={
"free_gb": free_gb,
"total_gb": total_gb,

View File

@@ -12,14 +12,14 @@ import logging
import shutil
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import Enum
from enum import StrEnum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class BackupPolicy(str, Enum):
class BackupPolicy(StrEnum):
"""Backup retention policies."""
DAILY = "daily"
@@ -69,9 +69,7 @@ class BackupScheduler:
for d in [self.daily_dir, self.weekly_dir, self.monthly_dir]:
d.mkdir(parents=True, exist_ok=True)
def create_backup(
self, policy: BackupPolicy, verify: bool = True
) -> BackupMetadata:
def create_backup(self, policy: BackupPolicy, verify: bool = True) -> BackupMetadata:
"""Create a database backup.
Args:
@@ -229,9 +227,7 @@ class BackupScheduler:
return removed
def list_backups(
self, policy: BackupPolicy | None = None
) -> list[BackupMetadata]:
def list_backups(self, policy: BackupPolicy | None = None) -> list[BackupMetadata]:
"""List available backups.
Args:

View File

@@ -13,8 +13,8 @@ import hashlib
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any, TYPE_CHECKING
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from src.brain.gemini_client import TradeDecision
@@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
class CacheEntry:
"""Cached decision with metadata."""
decision: "TradeDecision"
decision: TradeDecision
cached_at: float # Unix timestamp
hit_count: int = 0
market_data_hash: str = ""
@@ -239,9 +239,7 @@ class DecisionCache:
"""
current_time = time.time()
expired_keys = [
k
for k, v in self._cache.items()
if current_time - v.cached_at > self.ttl_seconds
k for k, v in self._cache.items() if current_time - v.cached_at > self.ttl_seconds
]
count = len(expired_keys)

View File

@@ -11,14 +11,14 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import Enum
from enum import StrEnum
from typing import Any
from src.context.layer import ContextLayer
from src.context.store import ContextStore
class DecisionType(str, Enum):
class DecisionType(StrEnum):
"""Type of trading decision being made."""
NORMAL = "normal" # Regular trade decision
@@ -183,9 +183,7 @@ class ContextSelector:
ContextLayer.L1_LEGACY,
]
scores = {
layer: self.score_layer_relevance(layer, decision_type) for layer in all_layers
}
scores = {layer: self.score_layer_relevance(layer, decision_type) for layer in all_layers}
# Filter by minimum score
selected_layers = [layer for layer, score in scores.items() if score >= min_score]

View File

@@ -25,12 +25,12 @@ from typing import Any
from google import genai
from src.config import Settings
from src.data.news_api import NewsAPI, NewsSentiment
from src.data.economic_calendar import EconomicCalendar
from src.data.market_data import MarketData
from src.brain.cache import DecisionCache
from src.brain.prompt_optimizer import PromptOptimizer
from src.config import Settings
from src.data.economic_calendar import EconomicCalendar
from src.data.market_data import MarketData
from src.data.news_api import NewsAPI, NewsSentiment
logger = logging.getLogger(__name__)
@@ -159,16 +159,12 @@ class GeminiClient:
return ""
# Check for upcoming high-impact events
upcoming = self._economic_calendar.get_upcoming_events(
days_ahead=7, min_impact="HIGH"
)
upcoming = self._economic_calendar.get_upcoming_events(days_ahead=7, min_impact="HIGH")
if upcoming.high_impact_count == 0:
return ""
lines = [
f"Upcoming High-Impact Events: {upcoming.high_impact_count} in next 7 days"
]
lines = [f"Upcoming High-Impact Events: {upcoming.high_impact_count} in next 7 days"]
if upcoming.next_major_event is not None:
event = upcoming.next_major_event
@@ -180,9 +176,7 @@ class GeminiClient:
# Check for earnings
earnings_date = self._economic_calendar.get_earnings_date(stock_code)
if earnings_date is not None:
lines.append(
f" Earnings: {stock_code} on {earnings_date.strftime('%Y-%m-%d')}"
)
lines.append(f" Earnings: {stock_code} on {earnings_date.strftime('%Y-%m-%d')}")
return "\n".join(lines)
@@ -235,9 +229,7 @@ class GeminiClient:
# Add foreigner net if non-zero
if market_data.get("foreigner_net", 0) != 0:
market_info_lines.append(
f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}"
)
market_info_lines.append(f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}")
market_info = "\n".join(market_info_lines)
@@ -249,8 +241,7 @@ class GeminiClient:
market_info += f"\n\n{external_context}"
json_format = (
'{"action": "BUY"|"SELL"|"HOLD", '
'"confidence": <int 0-100>, "rationale": "<string>"}'
'{"action": "BUY"|"SELL"|"HOLD", "confidence": <int 0-100>, "rationale": "<string>"}'
)
return (
f"You are a professional {market_name} trading analyst.\n"
@@ -289,15 +280,12 @@ class GeminiClient:
# Add foreigner net if non-zero
if market_data.get("foreigner_net", 0) != 0:
market_info_lines.append(
f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}"
)
market_info_lines.append(f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}")
market_info = "\n".join(market_info_lines)
json_format = (
'{"action": "BUY"|"SELL"|"HOLD", '
'"confidence": <int 0-100>, "rationale": "<string>"}'
'{"action": "BUY"|"SELL"|"HOLD", "confidence": <int 0-100>, "rationale": "<string>"}'
)
return (
f"You are a professional {market_name} trading analyst.\n"
@@ -339,25 +327,19 @@ class GeminiClient:
data = json.loads(cleaned)
except json.JSONDecodeError:
logger.warning("Malformed JSON from Gemini — defaulting to HOLD")
return TradeDecision(
action="HOLD", confidence=0, rationale="Malformed JSON response"
)
return TradeDecision(action="HOLD", confidence=0, rationale="Malformed JSON response")
# Validate required fields
if not all(k in data for k in ("action", "confidence", "rationale")):
logger.warning("Missing fields in Gemini response — defaulting to HOLD")
# Preserve raw text in rationale so prompt_override callers (e.g. pre_market_planner)
# can extract their own JSON format from decision.rationale (#245)
return TradeDecision(
action="HOLD", confidence=0, rationale=raw
)
return TradeDecision(action="HOLD", confidence=0, rationale=raw)
action = str(data["action"]).upper()
if action not in VALID_ACTIONS:
logger.warning("Invalid action '%s' from Gemini — defaulting to HOLD", action)
return TradeDecision(
action="HOLD", confidence=0, rationale=f"Invalid action: {action}"
)
return TradeDecision(action="HOLD", confidence=0, rationale=f"Invalid action: {action}")
confidence = int(data["confidence"])
rationale = str(data["rationale"])
@@ -445,9 +427,7 @@ class GeminiClient:
# not a parsed TradeDecision. Skip parse_response to avoid spurious
# "Missing fields" warnings and return the raw response directly. (#247)
if "prompt_override" in market_data:
logger.info(
"Gemini raw response received (prompt_override, tokens=%d)", token_count
)
logger.info("Gemini raw response received (prompt_override, tokens=%d)", token_count)
# Not a trade decision — don't inflate _total_decisions metrics
return TradeDecision(
action="HOLD", confidence=0, rationale=raw, token_count=token_count
@@ -546,9 +526,7 @@ class GeminiClient:
# Batch Decision Making (for daily trading mode)
# ------------------------------------------------------------------
async def decide_batch(
self, stocks_data: list[dict[str, Any]]
) -> dict[str, TradeDecision]:
async def decide_batch(self, stocks_data: list[dict[str, Any]]) -> dict[str, TradeDecision]:
"""Make decisions for multiple stocks in a single API call.
This is designed for daily trading mode to minimize API usage

View File

@@ -179,7 +179,8 @@ class PromptOptimizer:
# Minimal instructions
prompt = (
f"{market_name} trader. Analyze:\n{data_str}\n\n"
'Return JSON: {"action":"BUY"|"SELL"|"HOLD","confidence":<0-100>,"rationale":"<text>"}\n'
"Return JSON: "
'{"action":"BUY"|"SELL"|"HOLD","confidence":<0-100>,"rationale":"<text>"}\n'
"Rules: action=BUY/SELL/HOLD, confidence=0-100, rationale=concise. No markdown."
)
else:

View File

@@ -58,7 +58,7 @@ class LeakyBucket:
def __init__(self, rate: float) -> None:
"""Args:
rate: Maximum requests per second.
rate: Maximum requests per second.
"""
self._rate = rate
self._interval = 1.0 / rate
@@ -103,7 +103,8 @@ class KISBroker:
ssl_ctx.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_ctx)
self._session = aiohttp.ClientSession(
timeout=timeout, connector=connector,
timeout=timeout,
connector=connector,
)
return self._session
@@ -224,16 +225,12 @@ class KISBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_orderbook failed ({resp.status}): {text}"
)
raise ConnectionError(f"get_orderbook failed ({resp.status}): {text}")
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc
async def get_current_price(
self, stock_code: str
) -> tuple[float, float, float]:
async def get_current_price(self, stock_code: str) -> tuple[float, float, float]:
"""Fetch current price data for a domestic stock.
Uses the ``inquire-price`` API (FHKST01010100), which works in both
@@ -265,9 +262,7 @@ class KISBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_current_price failed ({resp.status}): {text}"
)
raise ConnectionError(f"get_current_price failed ({resp.status}): {text}")
data = await resp.json()
out = data.get("output", {})
return (
@@ -276,9 +271,7 @@ class KISBroker:
_f(out.get("frgn_ntby_qty")),
)
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching current price: {exc}"
) from exc
raise ConnectionError(f"Network error fetching current price: {exc}") from exc
async def get_balance(self) -> dict[str, Any]:
"""Fetch current account balance and holdings."""
@@ -308,9 +301,7 @@ class KISBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_balance failed ({resp.status}): {text}"
)
raise ConnectionError(f"get_balance failed ({resp.status}): {text}")
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching balance: {exc}") from exc
@@ -369,9 +360,7 @@ class KISBroker:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"send_order failed ({resp.status}): {text}"
)
raise ConnectionError(f"send_order failed ({resp.status}): {text}")
data = await resp.json()
logger.info(
"Order submitted",
@@ -449,9 +438,7 @@ class KISBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"fetch_market_rankings failed ({resp.status}): {text}"
)
raise ConnectionError(f"fetch_market_rankings failed ({resp.status}): {text}")
data = await resp.json()
# Parse response - output is a list of ranked stocks
@@ -465,14 +452,16 @@ class KISBroker:
rankings = []
for item in data.get("output", [])[:limit]:
rankings.append({
"stock_code": item.get("stck_shrn_iscd") or item.get("mksc_shrn_iscd", ""),
"name": item.get("hts_kor_isnm", ""),
"price": _safe_float(item.get("stck_prpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")),
"change_rate": _safe_float(item.get("prdy_ctrt", "0")),
"volume_increase_rate": _safe_float(item.get("vol_inrt", "0")),
})
rankings.append(
{
"stock_code": item.get("stck_shrn_iscd") or item.get("mksc_shrn_iscd", ""),
"name": item.get("hts_kor_isnm", ""),
"price": _safe_float(item.get("stck_prpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")),
"change_rate": _safe_float(item.get("prdy_ctrt", "0")),
"volume_increase_rate": _safe_float(item.get("vol_inrt", "0")),
}
)
return rankings
except (TimeoutError, aiohttp.ClientError) as exc:
@@ -522,9 +511,7 @@ class KISBroker:
data = await resp.json()
return data.get("output", []) or []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching domestic pending orders: {exc}"
) from exc
raise ConnectionError(f"Network error fetching domestic pending orders: {exc}") from exc
async def cancel_domestic_order(
self,
@@ -575,14 +562,10 @@ class KISBroker:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"cancel_domestic_order failed ({resp.status}): {text}"
)
raise ConnectionError(f"cancel_domestic_order failed ({resp.status}): {text}")
return cast(dict[str, Any], await resp.json())
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error cancelling domestic order: {exc}"
) from exc
raise ConnectionError(f"Network error cancelling domestic order: {exc}") from exc
async def get_daily_prices(
self,
@@ -609,6 +592,7 @@ class KISBroker:
# Calculate date range (today and N days ago)
from datetime import datetime, timedelta
end_date = datetime.now().strftime("%Y%m%d")
start_date = (datetime.now() - timedelta(days=days + 10)).strftime("%Y%m%d")
@@ -627,9 +611,7 @@ class KISBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_daily_prices failed ({resp.status}): {text}"
)
raise ConnectionError(f"get_daily_prices failed ({resp.status}): {text}")
data = await resp.json()
# Parse response
@@ -643,14 +625,16 @@ class KISBroker:
prices = []
for item in data.get("output2", []):
prices.append({
"date": item.get("stck_bsop_date", ""),
"open": _safe_float(item.get("stck_oprc", "0")),
"high": _safe_float(item.get("stck_hgpr", "0")),
"low": _safe_float(item.get("stck_lwpr", "0")),
"close": _safe_float(item.get("stck_clpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")),
})
prices.append(
{
"date": item.get("stck_bsop_date", ""),
"open": _safe_float(item.get("stck_oprc", "0")),
"high": _safe_float(item.get("stck_hgpr", "0")),
"low": _safe_float(item.get("stck_lwpr", "0")),
"close": _safe_float(item.get("stck_clpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")),
}
)
# Sort oldest to newest (KIS returns newest first)
prices.reverse()

View File

@@ -36,11 +36,11 @@ _CANCEL_TR_ID_MAP: dict[str, tuple[str, str]] = {
"NYSE": ("TTTT1004U", "VTTT1004U"),
"AMEX": ("TTTT1004U", "VTTT1004U"),
"SEHK": ("TTTS1003U", "VTTS1003U"),
"TSE": ("TTTS0309U", "VTTS0309U"),
"TSE": ("TTTS0309U", "VTTS0309U"),
"SHAA": ("TTTS0302U", "VTTS0302U"),
"SZAA": ("TTTS0306U", "VTTS0306U"),
"HNX": ("TTTS0312U", "VTTS0312U"),
"HSX": ("TTTS0312U", "VTTS0312U"),
"HNX": ("TTTS0312U", "VTTS0312U"),
"HSX": ("TTTS0312U", "VTTS0312U"),
}
@@ -56,9 +56,7 @@ class OverseasBroker:
"""
self._broker = kis_broker
async def get_overseas_price(
self, exchange_code: str, stock_code: str
) -> dict[str, Any]:
async def get_overseas_price(self, exchange_code: str, stock_code: str) -> dict[str, Any]:
"""
Fetch overseas stock price.
@@ -89,14 +87,10 @@ class OverseasBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_overseas_price failed ({resp.status}): {text}"
)
raise ConnectionError(f"get_overseas_price failed ({resp.status}): {text}")
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching overseas price: {exc}"
) from exc
raise ConnectionError(f"Network error fetching overseas price: {exc}") from exc
async def fetch_overseas_rankings(
self,
@@ -154,9 +148,7 @@ class OverseasBroker:
ranking_type,
)
return []
raise ConnectionError(
f"fetch_overseas_rankings failed ({resp.status}): {text}"
)
raise ConnectionError(f"fetch_overseas_rankings failed ({resp.status}): {text}")
data = await resp.json()
rows = self._extract_ranking_rows(data)
@@ -171,9 +163,7 @@ class OverseasBroker:
)
return []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching overseas rankings: {exc}"
) from exc
raise ConnectionError(f"Network error fetching overseas rankings: {exc}") from exc
async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]:
"""
@@ -193,9 +183,7 @@ class OverseasBroker:
# TR_ID: 실전 TTTS3012R, 모의 VTTS3012R
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 잔고조회' 시트
balance_tr_id = (
"TTTS3012R" if self._broker._settings.MODE == "live" else "VTTS3012R"
)
balance_tr_id = "TTTS3012R" if self._broker._settings.MODE == "live" else "VTTS3012R"
headers = await self._broker._auth_headers(balance_tr_id)
params = {
"CANO": self._broker._account_no,
@@ -205,22 +193,16 @@ class OverseasBroker:
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": "",
}
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-balance"
)
url = f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-balance"
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_overseas_balance failed ({resp.status}): {text}"
)
raise ConnectionError(f"get_overseas_balance failed ({resp.status}): {text}")
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching overseas balance: {exc}"
) from exc
raise ConnectionError(f"Network error fetching overseas balance: {exc}") from exc
async def get_overseas_buying_power(
self,
@@ -247,9 +229,7 @@ class OverseasBroker:
# TR_ID: 실전 TTTS3007R, 모의 VTTS3007R
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 매수가능금액조회' 시트
ps_tr_id = (
"TTTS3007R" if self._broker._settings.MODE == "live" else "VTTS3007R"
)
ps_tr_id = "TTTS3007R" if self._broker._settings.MODE == "live" else "VTTS3007R"
headers = await self._broker._auth_headers(ps_tr_id)
params = {
"CANO": self._broker._account_no,
@@ -258,9 +238,7 @@ class OverseasBroker:
"OVRS_ORD_UNPR": f"{price:.2f}",
"ITEM_CD": stock_code,
}
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-psamount"
)
url = f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-psamount"
try:
async with session.get(url, headers=headers, params=params) as resp:
@@ -271,9 +249,7 @@ class OverseasBroker:
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching overseas buying power: {exc}"
) from exc
raise ConnectionError(f"Network error fetching overseas buying power: {exc}") from exc
async def send_overseas_order(
self,
@@ -330,9 +306,7 @@ class OverseasBroker:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"send_overseas_order failed ({resp.status}): {text}"
)
raise ConnectionError(f"send_overseas_order failed ({resp.status}): {text}")
data = await resp.json()
rt_cd = data.get("rt_cd", "")
msg1 = data.get("msg1", "")
@@ -357,13 +331,9 @@ class OverseasBroker:
)
return data
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error sending overseas order: {exc}"
) from exc
raise ConnectionError(f"Network error sending overseas order: {exc}") from exc
async def get_overseas_pending_orders(
self, exchange_code: str
) -> list[dict[str, Any]]:
async def get_overseas_pending_orders(self, exchange_code: str) -> list[dict[str, Any]]:
"""Fetch unfilled (pending) overseas orders for a given exchange.
Args:
@@ -379,9 +349,7 @@ class OverseasBroker:
ConnectionError: On network or API errors (live mode only).
"""
if self._broker._settings.MODE != "live":
logger.debug(
"Pending orders API (TTTS3018R) not supported in paper mode; returning []"
)
logger.debug("Pending orders API (TTTS3018R) not supported in paper mode; returning []")
return []
await self._broker._rate_limiter.acquire()
@@ -398,9 +366,7 @@ class OverseasBroker:
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": "",
}
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-nccs"
)
url = f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-nccs"
try:
async with session.get(url, headers=headers, params=params) as resp:
@@ -415,9 +381,7 @@ class OverseasBroker:
return output
return []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching pending orders: {exc}"
) from exc
raise ConnectionError(f"Network error fetching pending orders: {exc}") from exc
async def cancel_overseas_order(
self,
@@ -469,22 +433,16 @@ class OverseasBroker:
headers = await self._broker._auth_headers(tr_id)
headers["hashkey"] = hash_key
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/order-rvsecncl"
)
url = f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/order-rvsecncl"
try:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"cancel_overseas_order failed ({resp.status}): {text}"
)
raise ConnectionError(f"cancel_overseas_order failed ({resp.status}): {text}")
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error cancelling overseas order: {exc}"
) from exc
raise ConnectionError(f"Network error cancelling overseas order: {exc}") from exc
def _get_currency_code(self, exchange_code: str) -> str:
"""

View File

@@ -111,25 +111,21 @@ class Settings(BaseSettings):
# Telegram notification type filters (granular control)
# circuit_breaker is always sent regardless — safety-critical
TELEGRAM_NOTIFY_TRADES: bool = True # BUY/SELL execution alerts
TELEGRAM_NOTIFY_TRADES: bool = True # BUY/SELL execution alerts
TELEGRAM_NOTIFY_MARKET_OPEN_CLOSE: bool = True # Market open/close alerts
TELEGRAM_NOTIFY_FAT_FINGER: bool = True # Fat-finger rejection alerts
TELEGRAM_NOTIFY_SYSTEM_EVENTS: bool = True # System start/shutdown alerts
TELEGRAM_NOTIFY_PLAYBOOK: bool = True # Playbook generated/failed alerts
TELEGRAM_NOTIFY_SCENARIO_MATCH: bool = True # Scenario matched alerts (most frequent)
TELEGRAM_NOTIFY_ERRORS: bool = True # Error alerts
TELEGRAM_NOTIFY_FAT_FINGER: bool = True # Fat-finger rejection alerts
TELEGRAM_NOTIFY_SYSTEM_EVENTS: bool = True # System start/shutdown alerts
TELEGRAM_NOTIFY_PLAYBOOK: bool = True # Playbook generated/failed alerts
TELEGRAM_NOTIFY_SCENARIO_MATCH: bool = True # Scenario matched alerts (most frequent)
TELEGRAM_NOTIFY_ERRORS: bool = True # Error alerts
# Overseas ranking API (KIS endpoint/TR_ID may vary by account/product)
# Override these from .env if your account uses different specs.
OVERSEAS_RANKING_ENABLED: bool = True
OVERSEAS_RANKING_FLUCT_TR_ID: str = "HHDFS76290000"
OVERSEAS_RANKING_VOLUME_TR_ID: str = "HHDFS76270000"
OVERSEAS_RANKING_FLUCT_PATH: str = (
"/uapi/overseas-stock/v1/ranking/updown-rate"
)
OVERSEAS_RANKING_VOLUME_PATH: str = (
"/uapi/overseas-stock/v1/ranking/volume-surge"
)
OVERSEAS_RANKING_FLUCT_PATH: str = "/uapi/overseas-stock/v1/ranking/updown-rate"
OVERSEAS_RANKING_VOLUME_PATH: str = "/uapi/overseas-stock/v1/ranking/volume-surge"
# Dashboard (optional)
DASHBOARD_ENABLED: bool = False

View File

@@ -222,9 +222,7 @@ class ContextAggregator:
total_pnl = 0.0
for month in months:
monthly_pnl = self.store.get_context(
ContextLayer.L4_MONTHLY, month, "monthly_pnl"
)
monthly_pnl = self.store.get_context(ContextLayer.L4_MONTHLY, month, "monthly_pnl")
if monthly_pnl is not None:
total_pnl += monthly_pnl
@@ -251,9 +249,7 @@ class ContextAggregator:
if quarterly_pnl is not None:
total_pnl += quarterly_pnl
self.store.set_context(
ContextLayer.L2_ANNUAL, year, "annual_pnl", round(total_pnl, 2)
)
self.store.set_context(ContextLayer.L2_ANNUAL, year, "annual_pnl", round(total_pnl, 2))
def aggregate_legacy_from_annual(self) -> None:
"""Aggregate L1 (legacy) context from all L2 (annual) data."""
@@ -280,9 +276,7 @@ class ContextAggregator:
self.store.set_context(
ContextLayer.L1_LEGACY, "LEGACY", "total_pnl", round(total_pnl, 2)
)
self.store.set_context(
ContextLayer.L1_LEGACY, "LEGACY", "years_traded", years_traded
)
self.store.set_context(ContextLayer.L1_LEGACY, "LEGACY", "years_traded", years_traded)
self.store.set_context(
ContextLayer.L1_LEGACY,
"LEGACY",

View File

@@ -3,10 +3,10 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from enum import StrEnum
class ContextLayer(str, Enum):
class ContextLayer(StrEnum):
"""7-tier context hierarchy from real-time to generational."""
L1_LEGACY = "L1_LEGACY" # Cumulative/generational wisdom

View File

@@ -9,7 +9,7 @@ This module summarizes old context data instead of including raw details:
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from typing import Any
from src.context.layer import ContextLayer

View File

@@ -11,8 +11,9 @@ Order is fixed:
from __future__ import annotations
import inspect
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
from typing import Any
StepCallable = Callable[[], Any | Awaitable[Any]]

View File

@@ -15,7 +15,7 @@ from src.markets.schedule import MarketInfo
_LOW_LIQUIDITY_SESSIONS = {"NXT_AFTER", "US_PRE", "US_DAY", "US_AFTER"}
class OrderPolicyRejected(Exception):
class OrderPolicyRejectedError(Exception):
"""Raised when an order violates session policy."""
def __init__(self, message: str, *, session_id: str, market_code: str) -> None:
@@ -61,7 +61,9 @@ def classify_session_id(market: MarketInfo, now: datetime | None = None) -> str:
def get_session_info(market: MarketInfo, now: datetime | None = None) -> SessionInfo:
session_id = classify_session_id(market, now)
return SessionInfo(session_id=session_id, is_low_liquidity=session_id in _LOW_LIQUIDITY_SESSIONS)
return SessionInfo(
session_id=session_id, is_low_liquidity=session_id in _LOW_LIQUIDITY_SESSIONS
)
def validate_order_policy(
@@ -76,7 +78,7 @@ def validate_order_policy(
is_market_order = price <= 0
if info.is_low_liquidity and is_market_order:
raise OrderPolicyRejected(
raise OrderPolicyRejectedError(
f"Market order is forbidden in low-liquidity session ({info.session_id})",
session_id=info.session_id,
market_code=market.code,
@@ -84,10 +86,14 @@ def validate_order_policy(
# Guard against accidental unsupported actions.
if order_type not in {"BUY", "SELL"}:
raise OrderPolicyRejected(
raise OrderPolicyRejectedError(
f"Unsupported order_type={order_type}",
session_id=info.session_id,
market_code=market.code,
)
return info
# Backward compatibility alias
OrderPolicyRejected = OrderPolicyRejectedError

View File

@@ -28,9 +28,7 @@ class PriorityTask:
# Task data not used in comparison
task_id: str = field(compare=False)
task_data: dict[str, Any] = field(compare=False, default_factory=dict)
callback: Callable[[], Coroutine[Any, Any, Any]] | None = field(
compare=False, default=None
)
callback: Callable[[], Coroutine[Any, Any, Any]] | None = field(compare=False, default=None)
@dataclass

View File

@@ -25,7 +25,7 @@ class CircuitBreakerTripped(SystemExit):
)
class FatFingerRejected(Exception):
class FatFingerRejectedError(Exception):
"""Raised when an order exceeds the maximum allowed proportion of cash."""
def __init__(self, order_amount: float, total_cash: float, max_pct: float) -> None:
@@ -61,7 +61,7 @@ class RiskManager:
def check_fat_finger(self, order_amount: float, total_cash: float) -> None:
"""Reject orders that exceed the maximum proportion of available cash."""
if total_cash <= 0:
raise FatFingerRejected(order_amount, total_cash, self._ff_max_pct)
raise FatFingerRejectedError(order_amount, total_cash, self._ff_max_pct)
ratio_pct = (order_amount / total_cash) * 100
if ratio_pct > self._ff_max_pct:
@@ -69,7 +69,7 @@ class RiskManager:
"Fat finger check failed",
extra={"order_amount": order_amount},
)
raise FatFingerRejected(order_amount, total_cash, self._ff_max_pct)
raise FatFingerRejectedError(order_amount, total_cash, self._ff_max_pct)
def validate_order(
self,
@@ -81,3 +81,7 @@ class RiskManager:
self.check_circuit_breaker(current_pnl_pct)
self.check_fat_finger(order_amount, total_cash)
logger.info("Order passed risk validation")
# Backward compatibility alias
FatFingerRejected = FatFingerRejectedError

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import json
import os
import sqlite3
from datetime import UTC, datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -188,10 +188,7 @@ def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
return {
"market": "all",
"combined": combined,
"by_market": [
_row_to_performance(row)
for row in by_market_rows
],
"by_market": [_row_to_performance(row) for row in by_market_rows],
}
row = conn.execute(
@@ -401,7 +398,7 @@ def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
"""
).fetchall()
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
positions = []
for row in rows:
entry_time_str = row["entry_time"]

View File

@@ -9,7 +9,6 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
logger = logging.getLogger(__name__)

View File

@@ -123,8 +123,7 @@ def init_db(db_path: str) -> sqlite3.Connection:
"""
)
decision_columns = {
row[1]
for row in conn.execute("PRAGMA table_info(decision_logs)").fetchall()
row[1] for row in conn.execute("PRAGMA table_info(decision_logs)").fetchall()
}
if "session_id" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN session_id TEXT DEFAULT 'UNKNOWN'")
@@ -185,9 +184,7 @@ def init_db(db_path: str) -> sqlite3.Connection:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_decision_logs_timestamp ON decision_logs(timestamp)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_decision_logs_reviewed ON decision_logs(reviewed)"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_decision_logs_reviewed ON decision_logs(reviewed)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)"
)
@@ -381,9 +378,7 @@ def get_open_position(
return {"decision_id": row[1], "price": row[2], "quantity": row[3], "timestamp": row[4]}
def get_recent_symbols(
conn: sqlite3.Connection, market: str, limit: int = 30
) -> list[str]:
def get_recent_symbols(conn: sqlite3.Connection, market: str, limit: int = 30) -> list[str]:
"""Return recent unique symbols for a market, newest first."""
cursor = conn.execute(
"""

View File

@@ -90,9 +90,7 @@ class ABTester:
sharpe_ratio = None
if len(pnls) > 1:
mean_return = avg_pnl
std_return = (
sum((p - mean_return) ** 2 for p in pnls) / (len(pnls) - 1)
) ** 0.5
std_return = (sum((p - mean_return) ** 2 for p in pnls) / (len(pnls) - 1)) ** 0.5
if std_return > 0:
sharpe_ratio = mean_return / std_return
@@ -198,8 +196,7 @@ class ABTester:
if meets_criteria:
logger.info(
"Strategy '%s' meets deployment criteria: "
"win_rate=%.2f%%, trades=%d, avg_pnl=%.2f",
"Strategy '%s' meets deployment criteria: win_rate=%.2f%%, trades=%d, avg_pnl=%.2f",
result.winner,
winning_perf.win_rate,
winning_perf.total_trades,

View File

@@ -60,9 +60,7 @@ class DailyReviewer:
if isinstance(scenario_match, dict) and scenario_match:
matched += 1
scenario_match_rate = (
round((matched / total_decisions) * 100, 2)
if total_decisions
else 0.0
round((matched / total_decisions) * 100, 2) if total_decisions else 0.0
)
trade_stats = self._conn.execute(

View File

@@ -80,26 +80,26 @@ class EvolutionOptimizer:
# Convert to dict format for analysis
failures = []
for decision in losing_decisions:
failures.append({
"decision_id": decision.decision_id,
"timestamp": decision.timestamp,
"stock_code": decision.stock_code,
"market": decision.market,
"exchange_code": decision.exchange_code,
"action": decision.action,
"confidence": decision.confidence,
"rationale": decision.rationale,
"outcome_pnl": decision.outcome_pnl,
"outcome_accuracy": decision.outcome_accuracy,
"context_snapshot": decision.context_snapshot,
"input_data": decision.input_data,
})
failures.append(
{
"decision_id": decision.decision_id,
"timestamp": decision.timestamp,
"stock_code": decision.stock_code,
"market": decision.market,
"exchange_code": decision.exchange_code,
"action": decision.action,
"confidence": decision.confidence,
"rationale": decision.rationale,
"outcome_pnl": decision.outcome_pnl,
"outcome_accuracy": decision.outcome_accuracy,
"context_snapshot": decision.context_snapshot,
"input_data": decision.input_data,
}
)
return failures
def identify_failure_patterns(
self, failures: list[dict[str, Any]]
) -> dict[str, Any]:
def identify_failure_patterns(self, failures: list[dict[str, Any]]) -> dict[str, Any]:
"""Identify patterns in losing decisions.
Analyzes:
@@ -143,12 +143,8 @@ class EvolutionOptimizer:
total_confidence += failure.get("confidence", 0)
total_loss += failure.get("outcome_pnl", 0.0)
patterns["avg_confidence"] = (
round(total_confidence / len(failures), 2) if failures else 0.0
)
patterns["avg_loss"] = (
round(total_loss / len(failures), 2) if failures else 0.0
)
patterns["avg_confidence"] = round(total_confidence / len(failures), 2) if failures else 0.0
patterns["avg_loss"] = round(total_loss / len(failures), 2) if failures else 0.0
# Convert Counters to regular dicts for JSON serialization
patterns["markets"] = dict(patterns["markets"])
@@ -197,7 +193,8 @@ class EvolutionOptimizer:
prompt = (
"You are a quantitative trading strategy developer.\n"
"Analyze these failed trades and their patterns, then generate an improved strategy.\n\n"
"Analyze these failed trades and their patterns, "
"then generate an improved strategy.\n\n"
f"Failure Patterns:\n{json.dumps(patterns, indent=2)}\n\n"
f"Sample Failed Trades (first 5):\n"
f"{json.dumps(failures[:5], indent=2, default=str)}\n\n"
@@ -214,7 +211,8 @@ class EvolutionOptimizer:
try:
response = await self._client.aio.models.generate_content(
model=self._model_name, contents=prompt,
model=self._model_name,
contents=prompt,
)
body = response.text.strip()
except Exception as exc:
@@ -280,9 +278,7 @@ class EvolutionOptimizer:
logger.info("Strategy validation PASSED")
return True
else:
logger.warning(
"Strategy validation FAILED:\n%s", result.stdout + result.stderr
)
logger.warning("Strategy validation FAILED:\n%s", result.stdout + result.stderr)
# Clean up failing strategy
strategy_path.unlink(missing_ok=True)
return False

View File

@@ -187,9 +187,7 @@ class PerformanceTracker:
return metrics
def calculate_improvement_trend(
self, metrics_history: list[StrategyMetrics]
) -> dict[str, Any]:
def calculate_improvement_trend(self, metrics_history: list[StrategyMetrics]) -> dict[str, Any]:
"""Calculate improvement trend from historical metrics.
Args:
@@ -229,9 +227,7 @@ class PerformanceTracker:
"period_count": len(metrics_history),
}
def generate_dashboard(
self, strategy_name: str | None = None
) -> PerformanceDashboard:
def generate_dashboard(self, strategy_name: str | None = None) -> PerformanceDashboard:
"""Generate a comprehensive performance dashboard.
Args:
@@ -260,9 +256,7 @@ class PerformanceTracker:
improvement_trend=improvement_trend,
)
def export_dashboard_json(
self, dashboard: PerformanceDashboard
) -> str:
def export_dashboard_json(self, dashboard: PerformanceDashboard) -> str:
"""Export dashboard as JSON string.
Args:

View File

@@ -140,9 +140,7 @@ class DecisionLogger:
)
self.conn.commit()
def update_outcome(
self, decision_id: str, pnl: float, accuracy: int
) -> None:
def update_outcome(self, decision_id: str, pnl: float, accuracy: int) -> None:
"""Update the outcome of a decision after trade execution.
Args:

View File

@@ -26,12 +26,12 @@ from src.context.aggregator import ContextAggregator
from src.context.layer import ContextLayer
from src.context.scheduler import ContextScheduler
from src.context.store import ContextStore
from src.core.criticality import CriticalityAssessor
from src.core.blackout_manager import (
BlackoutOrderManager,
QueuedOrderIntent,
parse_blackout_windows_kst,
)
from src.core.criticality import CriticalityAssessor
from src.core.kill_switch import KillSwitchOrchestrator
from src.core.order_policy import (
OrderPolicyRejected,
@@ -52,12 +52,16 @@ from src.evolution.optimizer import EvolutionOptimizer
from src.logging.decision_logger import DecisionLogger
from src.logging_config import setup_logging
from src.markets.schedule import MARKETS, MarketInfo, get_next_market_open, get_open_markets
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
from src.strategy.models import DayPlaybook, MarketOutlook
from src.notifications.telegram_client import (
NotificationFilter,
TelegramClient,
TelegramCommandHandler,
)
from src.strategy.exit_rules import ExitRuleConfig, ExitRuleInput, evaluate_exit
from src.strategy.models import DayPlaybook, MarketOutlook
from src.strategy.playbook_store import PlaybookStore
from src.strategy.pre_market_planner import PreMarketPlanner
from src.strategy.position_state_machine import PositionState
from src.strategy.pre_market_planner import PreMarketPlanner
from src.strategy.scenario_engine import ScenarioEngine
logger = logging.getLogger(__name__)
@@ -350,9 +354,7 @@ async def _inject_staged_exit_features(
return
if "pred_down_prob" not in market_data:
market_data["pred_down_prob"] = _estimate_pred_down_prob_from_rsi(
market_data.get("rsi")
)
market_data["pred_down_prob"] = _estimate_pred_down_prob_from_rsi(market_data.get("rsi"))
existing_atr = safe_float(market_data.get("atr_value"), 0.0)
if existing_atr > 0:
@@ -389,7 +391,7 @@ async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kw
return await coro_factory(*args, **kwargs)
except ConnectionError as exc:
if attempt < MAX_CONNECTION_RETRIES:
wait_secs = 2 ** attempt
wait_secs = 2**attempt
logger.warning(
"Connection error %s (attempt %d/%d), retrying in %ds: %s",
label,
@@ -413,7 +415,7 @@ async def sync_positions_from_broker(
broker: Any,
overseas_broker: Any,
db_conn: Any,
settings: "Settings",
settings: Settings,
) -> int:
"""Sync open positions from the live broker into the local DB at startup.
@@ -441,9 +443,7 @@ async def sync_positions_from_broker(
if market.exchange_code in seen_exchange_codes:
continue
seen_exchange_codes.add(market.exchange_code)
balance_data = await overseas_broker.get_overseas_balance(
market.exchange_code
)
balance_data = await overseas_broker.get_overseas_balance(market.exchange_code)
log_market = market_code # e.g. "US_NASDAQ"
except ConnectionError as exc:
logger.warning(
@@ -453,9 +453,7 @@ async def sync_positions_from_broker(
)
continue
held_codes = _extract_held_codes_from_balance(
balance_data, is_domestic=market.is_domestic
)
held_codes = _extract_held_codes_from_balance(balance_data, is_domestic=market.is_domestic)
for stock_code in held_codes:
if get_open_position(db_conn, stock_code, log_market):
continue # already tracked
@@ -487,9 +485,7 @@ async def sync_positions_from_broker(
synced += 1
if synced:
logger.info(
"Startup sync complete: %d position(s) synced from broker", synced
)
logger.info("Startup sync complete: %d position(s) synced from broker", synced)
else:
logger.info("Startup sync: no new positions to sync from broker")
return synced
@@ -859,15 +855,9 @@ def _apply_staged_exit_override_for_hold(
pnl_pct = (current_price - entry_price) / entry_price * 100.0
if exit_eval.reason == "hard_stop":
rationale = (
f"Stop-loss triggered ({pnl_pct:.2f}% <= "
f"{stop_loss_threshold:.2f}%)"
)
rationale = f"Stop-loss triggered ({pnl_pct:.2f}% <= {stop_loss_threshold:.2f}%)"
elif exit_eval.reason == "arm_take_profit":
rationale = (
f"Take-profit triggered ({pnl_pct:.2f}% >= "
f"{arm_pct:.2f}%)"
)
rationale = f"Take-profit triggered ({pnl_pct:.2f}% >= {arm_pct:.2f}%)"
elif exit_eval.reason == "atr_trailing_stop":
rationale = "ATR trailing-stop triggered"
elif exit_eval.reason == "be_lock_threat":
@@ -978,7 +968,10 @@ def _maybe_queue_order_intent(
)
if queued:
logger.warning(
"Blackout active: queued order intent %s %s (%s) qty=%d price=%.4f source=%s pending=%d",
(
"Blackout active: queued order intent %s %s (%s) "
"qty=%d price=%.4f source=%s pending=%d"
),
order_type,
stock_code,
market.code,
@@ -1071,7 +1064,10 @@ async def process_blackout_recovery_orders(
)
if queued_price <= 0 or current_price <= 0:
logger.info(
"Drop queued intent by price revalidation (invalid price): %s %s (%s) queued=%.4f current=%.4f",
(
"Drop queued intent by price revalidation (invalid price): "
"%s %s (%s) queued=%.4f current=%.4f"
),
intent.order_type,
intent.stock_code,
market.code,
@@ -1082,7 +1078,10 @@ async def process_blackout_recovery_orders(
drift_pct = abs(current_price - queued_price) / queued_price * 100.0
if drift_pct > max_drift_pct:
logger.info(
"Drop queued intent by price revalidation: %s %s (%s) queued=%.4f current=%.4f drift=%.2f%% max=%.2f%%",
(
"Drop queued intent by price revalidation: %s %s (%s) "
"queued=%.4f current=%.4f drift=%.2f%% max=%.2f%%"
),
intent.order_type,
intent.stock_code,
market.code,
@@ -1375,24 +1374,18 @@ async def trading_cycle(
# 1. Fetch market data
price_output: dict[str, Any] = {} # Populated for overseas markets; used for fallback metrics
if market.is_domestic:
current_price, price_change_pct, foreigner_net = await broker.get_current_price(
stock_code
)
current_price, price_change_pct, foreigner_net = await broker.get_current_price(stock_code)
balance_data = await broker.get_balance()
output2 = balance_data.get("output2", [{}])
total_eval = safe_float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
total_cash = safe_float(
balance_data.get("output2", [{}])[0].get("dnca_tot_amt", "0")
if output2
else "0"
balance_data.get("output2", [{}])[0].get("dnca_tot_amt", "0") if output2 else "0"
)
purchase_total = safe_float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0
else:
# Overseas market
price_data = await overseas_broker.get_overseas_price(
market.exchange_code, stock_code
)
price_data = await overseas_broker.get_overseas_price(market.exchange_code, stock_code)
balance_data = await overseas_broker.get_overseas_balance(market.exchange_code)
output2 = balance_data.get("output2", [{}])
@@ -1459,11 +1452,7 @@ async def trading_cycle(
total_cash = settings.PAPER_OVERSEAS_CASH
# Calculate daily P&L %
pnl_pct = (
((total_eval - purchase_total) / purchase_total * 100)
if purchase_total > 0
else 0.0
)
pnl_pct = ((total_eval - purchase_total) / purchase_total * 100) if purchase_total > 0 else 0.0
market_data: dict[str, Any] = {
"stock_code": stock_code,
@@ -1491,11 +1480,13 @@ async def trading_cycle(
market_data["rsi"] = max(0.0, min(100.0, 50.0 + price_change_pct * 2.0))
if price_output and current_price > 0:
pr_high = safe_float(
price_output.get("high") or price_output.get("ovrs_hgpr")
price_output.get("high")
or price_output.get("ovrs_hgpr")
or price_output.get("stck_hgpr")
)
pr_low = safe_float(
price_output.get("low") or price_output.get("ovrs_lwpr")
price_output.get("low")
or price_output.get("ovrs_lwpr")
or price_output.get("stck_lwpr")
)
if pr_high > 0 and pr_low > 0 and pr_high >= pr_low:
@@ -1512,9 +1503,7 @@ async def trading_cycle(
if open_pos and current_price > 0:
entry_price = safe_float(open_pos.get("price"), 0.0)
if entry_price > 0:
market_data["unrealized_pnl_pct"] = (
(current_price - entry_price) / entry_price * 100
)
market_data["unrealized_pnl_pct"] = (current_price - entry_price) / entry_price * 100
entry_ts = open_pos.get("timestamp")
if entry_ts:
try:
@@ -1745,16 +1734,19 @@ async def trading_cycle(
stock_playbook=stock_playbook,
settings=settings,
)
if open_position and decision.action == "HOLD" and _should_force_exit_for_overnight(
if (
open_position
and decision.action == "HOLD"
and _should_force_exit_for_overnight(
market=market,
settings=settings,
)
):
decision = TradeDecision(
action="SELL",
confidence=max(decision.confidence, 85),
rationale=(
"Forced exit by overnight policy"
" (session close window / kill switch priority)"
"Forced exit by overnight policy (session close window / kill switch priority)"
),
)
logger.info(
@@ -1834,9 +1826,7 @@ async def trading_cycle(
return
broker_held_qty = (
_extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic
)
_extract_held_qty_from_balance(balance_data, stock_code, is_domestic=market.is_domestic)
if decision.action == "SELL"
else 0
)
@@ -1871,7 +1861,10 @@ async def trading_cycle(
)
if fx_blocked:
logger.warning(
"Skip BUY %s (%s): FX buffer guard (remaining=%.2f, required=%.2f, cash=%.2f, order=%.2f)",
(
"Skip BUY %s (%s): FX buffer guard "
"(remaining=%.2f, required=%.2f, cash=%.2f, order=%.2f)"
),
stock_code,
market.name,
remaining_cash,
@@ -2068,8 +2061,7 @@ async def trading_cycle(
action="SELL",
confidence=0,
rationale=(
"[ghost-close] Broker reported no balance;"
" position closed without fill"
"[ghost-close] Broker reported no balance; position closed without fill"
),
quantity=0,
price=0.0,
@@ -2275,17 +2267,13 @@ async def handle_domestic_pending_orders(
outcome="cancelled",
)
except Exception as notify_exc:
logger.warning(
"notify_unfilled_order failed: %s", notify_exc
)
logger.warning("notify_unfilled_order failed: %s", notify_exc)
else:
# First unfilled SELL → resubmit at last * 0.996 (-0.4%).
try:
last_price, _, _ = await broker.get_current_price(stock_code)
if last_price <= 0:
raise ValueError(
f"Invalid price ({last_price}) for {stock_code}"
)
raise ValueError(f"Invalid price ({last_price}) for {stock_code}")
new_price = kr_round_down(last_price * 0.996)
validate_order_policy(
market=MARKETS["KR"],
@@ -2298,9 +2286,7 @@ async def handle_domestic_pending_orders(
quantity=psbl_qty,
price=new_price,
)
sell_resubmit_counts[key] = (
sell_resubmit_counts.get(key, 0) + 1
)
sell_resubmit_counts[key] = sell_resubmit_counts.get(key, 0) + 1
try:
await telegram.notify_unfilled_order(
stock_code=stock_code,
@@ -2311,9 +2297,7 @@ async def handle_domestic_pending_orders(
new_price=float(new_price),
)
except Exception as notify_exc:
logger.warning(
"notify_unfilled_order failed: %s", notify_exc
)
logger.warning("notify_unfilled_order failed: %s", notify_exc)
except Exception as exc:
logger.error(
"SELL resubmit failed for KR %s: %s",
@@ -2381,9 +2365,7 @@ async def handle_overseas_pending_orders(
try:
orders = await overseas_broker.get_overseas_pending_orders(exchange_code)
except Exception as exc:
logger.warning(
"Failed to fetch pending orders for %s: %s", exchange_code, exc
)
logger.warning("Failed to fetch pending orders for %s: %s", exchange_code, exc)
continue
for order in orders:
@@ -2448,26 +2430,21 @@ async def handle_overseas_pending_orders(
outcome="cancelled",
)
except Exception as notify_exc:
logger.warning(
"notify_unfilled_order failed: %s", notify_exc
)
logger.warning("notify_unfilled_order failed: %s", notify_exc)
else:
# First unfilled SELL → resubmit at last * 0.996 (-0.4%).
try:
price_data = await overseas_broker.get_overseas_price(
order_exchange, stock_code
)
last_price = float(
price_data.get("output", {}).get("last", "0") or "0"
)
last_price = float(price_data.get("output", {}).get("last", "0") or "0")
if last_price <= 0:
raise ValueError(
f"Invalid price ({last_price}) for {stock_code}"
)
raise ValueError(f"Invalid price ({last_price}) for {stock_code}")
new_price = round(last_price * 0.996, 4)
market_info = next(
(
m for m in MARKETS.values()
m
for m in MARKETS.values()
if m.exchange_code == order_exchange and not m.is_domestic
),
None,
@@ -2485,9 +2462,7 @@ async def handle_overseas_pending_orders(
quantity=nccs_qty,
price=new_price,
)
sell_resubmit_counts[key] = (
sell_resubmit_counts.get(key, 0) + 1
)
sell_resubmit_counts[key] = sell_resubmit_counts.get(key, 0) + 1
try:
await telegram.notify_unfilled_order(
stock_code=stock_code,
@@ -2498,9 +2473,7 @@ async def handle_overseas_pending_orders(
new_price=new_price,
)
except Exception as notify_exc:
logger.warning(
"notify_unfilled_order failed: %s", notify_exc
)
logger.warning("notify_unfilled_order failed: %s", notify_exc)
except Exception as exc:
logger.error(
"SELL resubmit failed for %s %s: %s",
@@ -2659,13 +2632,16 @@ async def run_daily_session(
logger.warning("Playbook notification failed: %s", exc)
logger.info(
"Generated playbook for %s: %d stocks, %d scenarios",
market.code, playbook.stock_count, playbook.scenario_count,
market.code,
playbook.stock_count,
playbook.scenario_count,
)
except Exception as exc:
logger.error("Playbook generation failed for %s: %s", market.code, exc)
try:
await telegram.notify_playbook_failed(
market=market.code, reason=str(exc)[:200],
market=market.code,
reason=str(exc)[:200],
)
except Exception as notify_exc:
logger.warning("Playbook failed notification error: %s", notify_exc)
@@ -2676,12 +2652,10 @@ async def run_daily_session(
for stock_code in watchlist:
try:
if market.is_domestic:
current_price, price_change_pct, foreigner_net = (
await _retry_connection(
broker.get_current_price,
stock_code,
label=stock_code,
)
current_price, price_change_pct, foreigner_net = await _retry_connection(
broker.get_current_price,
stock_code,
label=stock_code,
)
else:
price_data = await _retry_connection(
@@ -2690,9 +2664,7 @@ async def run_daily_session(
stock_code,
label=f"{stock_code}@{market.exchange_code}",
)
current_price = safe_float(
price_data.get("output", {}).get("last", "0")
)
current_price = safe_float(price_data.get("output", {}).get("last", "0"))
# Fallback: if price API returns 0, use scanner candidate price
if current_price <= 0:
cand_lookup = candidate_map.get(stock_code)
@@ -2704,9 +2676,7 @@ async def run_daily_session(
)
current_price = cand_lookup.price
foreigner_net = 0.0
price_change_pct = safe_float(
price_data.get("output", {}).get("rate", "0")
)
price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0"))
# Fall back to scanner candidate price if API returns 0.
if current_price <= 0:
cand_lookup = candidate_map.get(stock_code)
@@ -2769,15 +2739,9 @@ async def run_daily_session(
if market.is_domestic:
output2 = balance_data.get("output2", [{}])
total_eval = safe_float(
output2[0].get("tot_evlu_amt", "0")
) if output2 else 0
total_cash = safe_float(
output2[0].get("dnca_tot_amt", "0")
) if output2 else 0
purchase_total = safe_float(
output2[0].get("pchs_amt_smtl_amt", "0")
) if output2 else 0
total_eval = safe_float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
total_cash = safe_float(output2[0].get("dnca_tot_amt", "0")) if output2 else 0
purchase_total = safe_float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0
else:
output2 = balance_data.get("output2", [{}])
if isinstance(output2, list) and output2:
@@ -2788,18 +2752,15 @@ async def run_daily_session(
balance_info = {}
total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0")
purchase_total = safe_float(
balance_info.get("frcr_buy_amt_smtl", "0") or "0"
)
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
# Fetch available foreign currency cash via inquire-psamount (TTTS3007R/VTTS3007R).
# TTTS3012R output2 does not include a cash/deposit field — frcr_dncl_amt_2 does not exist.
# TTTS3012R output2 does not include a cash/deposit field.
# frcr_dncl_amt_2 does not exist.
# Use the first stock with a valid price as the reference for the buying power query.
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 매수가능금액조회' 시트
total_cash = 0.0
ref_stock = next(
(s for s in stocks_data if s.get("current_price", 0) > 0), None
)
ref_stock = next((s for s in stocks_data if s.get("current_price", 0) > 0), None)
if ref_stock:
try:
ps_data = await overseas_broker.get_overseas_buying_power(
@@ -2819,11 +2780,7 @@ async def run_daily_session(
# Paper mode fallback: VTS overseas balance API often fails for many accounts.
# Only activate in paper mode — live mode must use real balance from KIS.
if (
total_cash <= 0
and settings.MODE == "paper"
and settings.PAPER_OVERSEAS_CASH > 0
):
if total_cash <= 0 and settings.MODE == "paper" and settings.PAPER_OVERSEAS_CASH > 0:
total_cash = settings.PAPER_OVERSEAS_CASH
# Capture the day's opening portfolio value on the first market processed
@@ -2856,13 +2813,17 @@ async def run_daily_session(
# Evaluate scenarios for each stock (local, no API calls)
logger.info(
"Evaluating %d stocks against playbook for %s",
len(stocks_data), market.name,
len(stocks_data),
market.name,
)
for stock_data in stocks_data:
stock_code = stock_data["stock_code"]
stock_playbook = playbook.get_stock_playbook(stock_code)
match = scenario_engine.evaluate(
playbook, stock_code, stock_data, portfolio_data,
playbook,
stock_code,
stock_data,
portfolio_data,
)
decision = TradeDecision(
action=match.action.value,
@@ -2969,9 +2930,13 @@ async def run_daily_session(
stock_playbook=stock_playbook,
settings=settings,
)
if daily_open and decision.action == "HOLD" and _should_force_exit_for_overnight(
market=market,
settings=settings,
if (
daily_open
and decision.action == "HOLD"
and _should_force_exit_for_overnight(
market=market,
settings=settings,
)
):
decision = TradeDecision(
action="SELL",
@@ -3063,16 +3028,21 @@ async def run_daily_session(
)
continue
order_amount = stock_data["current_price"] * quantity
fx_blocked, remaining_cash, required_buffer = _should_block_overseas_buy_for_fx_buffer(
market=market,
action=decision.action,
total_cash=total_cash,
order_amount=order_amount,
settings=settings,
fx_blocked, remaining_cash, required_buffer = (
_should_block_overseas_buy_for_fx_buffer(
market=market,
action=decision.action,
total_cash=total_cash,
order_amount=order_amount,
settings=settings,
)
)
if fx_blocked:
logger.warning(
"Skip BUY %s (%s): FX buffer guard (remaining=%.2f, required=%.2f, cash=%.2f, order=%.2f)",
(
"Skip BUY %s (%s): FX buffer guard "
"(remaining=%.2f, required=%.2f, cash=%.2f, order=%.2f)"
),
stock_code,
market.name,
remaining_cash,
@@ -3090,7 +3060,10 @@ async def run_daily_session(
if now < daily_cooldown_until:
remaining = int(daily_cooldown_until - now)
logger.info(
"Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)",
(
"Skip BUY %s (%s): insufficient-balance cooldown active "
"(%ds remaining)"
),
stock_code,
market.name,
remaining,
@@ -3149,13 +3122,9 @@ async def run_daily_session(
# Use limit orders (지정가) for domestic stocks.
# KRX tick rounding applied via kr_round_down.
if decision.action == "BUY":
order_price = kr_round_down(
stock_data["current_price"] * 1.002
)
order_price = kr_round_down(stock_data["current_price"] * 1.002)
else:
order_price = kr_round_down(
stock_data["current_price"] * 0.998
)
order_price = kr_round_down(stock_data["current_price"] * 0.998)
try:
validate_order_policy(
market=market,
@@ -3260,9 +3229,7 @@ async def run_daily_session(
except Exception as exc:
logger.warning("Telegram notification failed: %s", exc)
except Exception as exc:
logger.error(
"Order execution failed for %s: %s", stock_code, exc
)
logger.error("Order execution failed for %s: %s", stock_code, exc)
continue
if decision.action == "SELL" and order_succeeded:
@@ -3286,7 +3253,9 @@ async def run_daily_session(
accuracy=1 if trade_pnl > 0 else 0,
)
if trade_pnl < 0:
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
cooldown_key = _stoploss_cooldown_key(
market=market, stock_code=stock_code
)
cooldown_minutes = _stoploss_cooldown_minutes(
settings,
market=market,
@@ -3369,7 +3338,8 @@ async def _handle_market_close(
def _run_context_scheduler(
scheduler: ContextScheduler, now: datetime | None = None,
scheduler: ContextScheduler,
now: datetime | None = None,
) -> None:
"""Run periodic context scheduler tasks and log when anything executes."""
result = scheduler.run_if_due(now=now)
@@ -3438,6 +3408,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
# reported synchronously (avoids the misleading "started" → "failed" log pair).
try:
import uvicorn # noqa: F401
from src.dashboard import create_dashboard_app # noqa: F401
except ImportError as exc:
logger.warning("Dashboard server unavailable (missing dependency): %s", exc)
@@ -3446,6 +3417,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
def _serve() -> None:
try:
import uvicorn
from src.dashboard import create_dashboard_app
app = create_dashboard_app(settings.DB_PATH, mode=settings.MODE)
@@ -3586,8 +3558,7 @@ async def run(settings: Settings) -> None:
pause_trading.set()
logger.info("Trading resumed via Telegram command")
await telegram.send_message(
"<b>▶️ Trading Resumed</b>\n\n"
"Trading operations have been restarted."
"<b>▶️ Trading Resumed</b>\n\nTrading operations have been restarted."
)
async def handle_status() -> None:
@@ -3630,9 +3601,7 @@ async def run(settings: Settings) -> None:
except Exception as exc:
logger.error("Error in /status handler: %s", exc)
await telegram.send_message(
"<b>⚠️ Error</b>\n\nFailed to retrieve trading status."
)
await telegram.send_message("<b>⚠️ Error</b>\n\nFailed to retrieve trading status.")
async def handle_positions() -> None:
"""Handle /positions command - show account summary."""
@@ -3643,8 +3612,7 @@ async def run(settings: Settings) -> None:
if not output2:
await telegram.send_message(
"<b>💼 Account Summary</b>\n\n"
"No balance information available."
"<b>💼 Account Summary</b>\n\nNo balance information available."
)
return
@@ -3673,9 +3641,7 @@ async def run(settings: Settings) -> None:
except Exception as exc:
logger.error("Error in /positions handler: %s", exc)
await telegram.send_message(
"<b>⚠️ Error</b>\n\nFailed to retrieve positions."
)
await telegram.send_message("<b>⚠️ Error</b>\n\nFailed to retrieve positions.")
async def handle_report() -> None:
"""Handle /report command - show daily summary metrics."""
@@ -3719,9 +3685,7 @@ async def run(settings: Settings) -> None:
)
except Exception as exc:
logger.error("Error in /report handler: %s", exc)
await telegram.send_message(
"<b>⚠️ Error</b>\n\nFailed to generate daily report."
)
await telegram.send_message("<b>⚠️ Error</b>\n\nFailed to generate daily report.")
async def handle_scenarios() -> None:
"""Handle /scenarios command - show today's playbook scenarios."""
@@ -3770,9 +3734,7 @@ async def run(settings: Settings) -> None:
await telegram.send_message("\n".join(lines).strip())
except Exception as exc:
logger.error("Error in /scenarios handler: %s", exc)
await telegram.send_message(
"<b>⚠️ Error</b>\n\nFailed to retrieve scenarios."
)
await telegram.send_message("<b>⚠️ Error</b>\n\nFailed to retrieve scenarios.")
async def handle_review() -> None:
"""Handle /review command - show recent scorecards."""
@@ -3788,9 +3750,7 @@ async def run(settings: Settings) -> None:
).fetchall()
if not rows:
await telegram.send_message(
"<b>📝 Recent Reviews</b>\n\nNo scorecards available."
)
await telegram.send_message("<b>📝 Recent Reviews</b>\n\nNo scorecards available.")
return
lines = ["<b>📝 Recent Reviews</b>", ""]
@@ -3808,9 +3768,7 @@ async def run(settings: Settings) -> None:
await telegram.send_message("\n".join(lines))
except Exception as exc:
logger.error("Error in /review handler: %s", exc)
await telegram.send_message(
"<b>⚠️ Error</b>\n\nFailed to retrieve reviews."
)
await telegram.send_message("<b>⚠️ Error</b>\n\nFailed to retrieve reviews.")
async def handle_notify(args: list[str]) -> None:
"""Handle /notify [key] [on|off] — query or change notification filters."""
@@ -3845,8 +3803,7 @@ async def run(settings: Settings) -> None:
else:
valid = ", ".join(list(status.keys()) + ["all"])
await telegram.send_message(
f"❌ 알 수 없는 키: <code>{key}</code>\n"
f"유효한 키: {valid}"
f"❌ 알 수 없는 키: <code>{key}</code>\n유효한 키: {valid}"
)
return
@@ -3858,30 +3815,22 @@ async def run(settings: Settings) -> None:
value = toggle == "on"
if telegram.set_notification(key, value):
icon = "" if value else ""
label = f"전체 알림" if key == "all" else f"<code>{key}</code> 알림"
label = "전체 알림" if key == "all" else f"<code>{key}</code> 알림"
state = "켜짐" if value else "꺼짐"
await telegram.send_message(f"{icon} {label}{state}")
logger.info("Notification filter changed via Telegram: %s=%s", key, value)
else:
valid = ", ".join(list(telegram.filter_status().keys()) + ["all"])
await telegram.send_message(
f"❌ 알 수 없는 키: <code>{key}</code>\n"
f"유효한 키: {valid}"
)
await telegram.send_message(f"❌ 알 수 없는 키: <code>{key}</code>\n유효한 키: {valid}")
async def handle_dashboard() -> None:
"""Handle /dashboard command - show dashboard URL if enabled."""
if not settings.DASHBOARD_ENABLED:
await telegram.send_message(
"<b>🖥️ Dashboard</b>\n\nDashboard is not enabled."
)
await telegram.send_message("<b>🖥️ Dashboard</b>\n\nDashboard is not enabled.")
return
url = f"http://{settings.DASHBOARD_HOST}:{settings.DASHBOARD_PORT}"
await telegram.send_message(
"<b>🖥️ Dashboard</b>\n\n"
f"<b>URL:</b> {url}"
)
await telegram.send_message(f"<b>🖥️ Dashboard</b>\n\n<b>URL:</b> {url}")
command_handler.register_command("help", handle_help)
command_handler.register_command("stop", handle_stop)
@@ -4182,9 +4131,7 @@ async def run(settings: Settings) -> None:
)
# Store candidates per market for selection context logging
scan_candidates[market.code] = {
c.stock_code: c for c in candidates
}
scan_candidates[market.code] = {c.stock_code: c for c in candidates}
logger.info(
"Smart Scanner: Found %d candidates for %s: %s",
@@ -4194,9 +4141,7 @@ async def run(settings: Settings) -> None:
)
# Get market-local date for playbook keying
market_today = datetime.now(
market.timezone
).date()
market_today = datetime.now(market.timezone).date()
# Load or generate playbook (1 Gemini call per market per day)
if market.code not in playbooks:
@@ -4234,7 +4179,8 @@ async def run(settings: Settings) -> None:
except Exception as exc:
logger.error(
"Playbook generation failed for %s: %s",
market.code, exc,
market.code,
exc,
)
try:
await telegram.notify_playbook_failed(
@@ -4279,7 +4225,8 @@ async def run(settings: Settings) -> None:
except Exception as exc:
logger.warning(
"Failed to fetch holdings for %s: %s — skipping holdings merge",
market.name, exc,
market.name,
exc,
)
held_codes = []
@@ -4288,7 +4235,8 @@ async def run(settings: Settings) -> None:
if extra_held:
logger.info(
"Holdings added to loop for %s (not in scanner): %s",
market.name, extra_held,
market.name,
extra_held,
)
if not stock_codes:

View File

@@ -211,9 +211,7 @@ def get_open_markets(
return is_market_open(market, now)
open_markets = [
MARKETS[code]
for code in enabled_markets
if code in MARKETS and is_available(MARKETS[code])
MARKETS[code] for code in enabled_markets if code in MARKETS and is_available(MARKETS[code])
]
return sorted(open_markets, key=lambda m: m.code)
@@ -282,9 +280,7 @@ def get_next_market_open(
# Calculate next open time for this market
for days_ahead in range(7): # Check next 7 days
check_date = market_now.date() + timedelta(days=days_ahead)
check_datetime = datetime.combine(
check_date, market.open_time, tzinfo=market.timezone
)
check_datetime = datetime.combine(check_date, market.open_time, tzinfo=market.timezone)
# Skip weekends
if check_datetime.weekday() >= 5:

View File

@@ -4,7 +4,7 @@ import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, fields
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar
@@ -136,14 +136,14 @@ class TelegramClient:
self._enabled = enabled
self._rate_limiter = LeakyBucket(rate=rate_limit)
self._session: aiohttp.ClientSession | None = None
self._filter = notification_filter if notification_filter is not None else NotificationFilter()
self._filter = (
notification_filter if notification_filter is not None else NotificationFilter()
)
if not enabled:
logger.info("Telegram notifications disabled via configuration")
elif bot_token is None or chat_id is None:
logger.warning(
"Telegram notifications disabled (missing bot_token or chat_id)"
)
logger.warning("Telegram notifications disabled (missing bot_token or chat_id)")
self._enabled = False
else:
logger.info("Telegram notifications enabled for chat_id=%s", chat_id)
@@ -209,14 +209,12 @@ class TelegramClient:
async with session.post(url, json=payload) as resp:
if resp.status != 200:
error_text = await resp.text()
logger.error(
"Telegram API error (status=%d): %s", resp.status, error_text
)
logger.error("Telegram API error (status=%d): %s", resp.status, error_text)
return False
logger.debug("Telegram message sent: %s", text[:50])
return True
except asyncio.TimeoutError:
except TimeoutError:
logger.error("Telegram message timeout")
return False
except aiohttp.ClientError as exc:
@@ -305,9 +303,7 @@ class TelegramClient:
NotificationMessage(priority=NotificationPriority.LOW, message=message)
)
async def notify_circuit_breaker(
self, pnl_pct: float, threshold: float
) -> None:
async def notify_circuit_breaker(self, pnl_pct: float, threshold: float) -> None:
"""
Notify circuit breaker activation.
@@ -354,9 +350,7 @@ class TelegramClient:
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
)
async def notify_system_start(
self, mode: str, enabled_markets: list[str]
) -> None:
async def notify_system_start(self, mode: str, enabled_markets: list[str]) -> None:
"""
Notify system startup.
@@ -369,9 +363,7 @@ class TelegramClient:
mode_emoji = "📝" if mode == "paper" else "💰"
markets_str = ", ".join(enabled_markets)
message = (
f"<b>{mode_emoji} System Started</b>\n"
f"Mode: {mode.upper()}\n"
f"Markets: {markets_str}"
f"<b>{mode_emoji} System Started</b>\nMode: {mode.upper()}\nMarkets: {markets_str}"
)
await self._send_notification(
NotificationMessage(priority=NotificationPriority.MEDIUM, message=message)
@@ -445,11 +437,7 @@ class TelegramClient:
"""
if not self._filter.playbook:
return
message = (
f"<b>Playbook Failed</b>\n"
f"Market: {market}\n"
f"Reason: {reason[:200]}"
)
message = f"<b>Playbook Failed</b>\nMarket: {market}\nReason: {reason[:200]}"
await self._send_notification(
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
)
@@ -469,9 +457,7 @@ class TelegramClient:
if "circuit breaker" in reason.lower()
else NotificationPriority.MEDIUM
)
await self._send_notification(
NotificationMessage(priority=priority, message=message)
)
await self._send_notification(NotificationMessage(priority=priority, message=message))
async def notify_unfilled_order(
self,
@@ -496,11 +482,7 @@ class TelegramClient:
return
# SELL resubmit is high priority — position liquidation at risk.
# BUY cancel is medium priority — only cash is freed.
priority = (
NotificationPriority.HIGH
if action == "SELL"
else NotificationPriority.MEDIUM
)
priority = NotificationPriority.HIGH if action == "SELL" else NotificationPriority.MEDIUM
outcome_emoji = "🔄" if outcome == "resubmitted" else ""
outcome_label = "재주문" if outcome == "resubmitted" else "취소됨"
action_emoji = "🔴" if action == "SELL" else "🟢"
@@ -515,9 +497,7 @@ class TelegramClient:
message = "\n".join(lines)
await self._send_notification(NotificationMessage(priority=priority, message=message))
async def notify_error(
self, error_type: str, error_msg: str, context: str
) -> None:
async def notify_error(self, error_type: str, error_msg: str, context: str) -> None:
"""
Notify system error.
@@ -541,9 +521,7 @@ class TelegramClient:
class TelegramCommandHandler:
"""Handles incoming Telegram commands via long polling."""
def __init__(
self, client: TelegramClient, polling_interval: float = 1.0
) -> None:
def __init__(self, client: TelegramClient, polling_interval: float = 1.0) -> None:
"""
Initialize command handler.
@@ -559,9 +537,7 @@ class TelegramCommandHandler:
self._polling_task: asyncio.Task[None] | None = None
self._running = False
def register_command(
self, command: str, handler: Callable[[], Awaitable[None]]
) -> None:
def register_command(self, command: str, handler: Callable[[], Awaitable[None]]) -> None:
"""
Register a command handler (no arguments).
@@ -672,7 +648,7 @@ class TelegramCommandHandler:
return updates
except asyncio.TimeoutError:
except TimeoutError:
logger.debug("getUpdates timeout (normal)")
return []
except aiohttp.ClientError as exc:
@@ -697,9 +673,7 @@ class TelegramCommandHandler:
# Verify chat_id matches configured chat
chat_id = str(message.get("chat", {}).get("id", ""))
if chat_id != self._client._chat_id:
logger.warning(
"Ignoring command from unauthorized chat_id: %s", chat_id
)
logger.warning("Ignoring command from unauthorized chat_id: %s", chat_id)
return
# Extract command text

View File

@@ -8,12 +8,12 @@ Defines the data contracts for the proactive strategy system:
from __future__ import annotations
from datetime import UTC, date, datetime
from enum import Enum
from enum import StrEnum
from pydantic import BaseModel, Field, field_validator
class ScenarioAction(str, Enum):
class ScenarioAction(StrEnum):
"""Actions that can be taken by scenarios."""
BUY = "BUY"
@@ -22,7 +22,7 @@ class ScenarioAction(str, Enum):
REDUCE_ALL = "REDUCE_ALL"
class MarketOutlook(str, Enum):
class MarketOutlook(StrEnum):
"""AI's assessment of market direction."""
BULLISH = "bullish"
@@ -32,7 +32,7 @@ class MarketOutlook(str, Enum):
BEARISH = "bearish"
class PlaybookStatus(str, Enum):
class PlaybookStatus(StrEnum):
"""Lifecycle status of a playbook."""
PENDING = "pending"

View File

@@ -6,7 +6,6 @@ Designed for the pre-market strategy system (one playbook per market per day).
from __future__ import annotations
import json
import logging
import sqlite3
from datetime import date
@@ -53,8 +52,10 @@ class PlaybookStore:
row_id = cursor.lastrowid or 0
logger.info(
"Saved playbook for %s/%s (%d stocks, %d scenarios)",
playbook.date, playbook.market,
playbook.stock_count, playbook.scenario_count,
playbook.date,
playbook.market,
playbook.stock_count,
playbook.scenario_count,
)
return row_id

View File

@@ -6,10 +6,10 @@ State progression is monotonic (promotion-only) except terminal EXITED.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from enum import StrEnum
class PositionState(str, Enum):
class PositionState(StrEnum):
HOLDING = "HOLDING"
BE_LOCK = "BE_LOCK"
ARMED = "ARMED"
@@ -40,12 +40,7 @@ def evaluate_exit_first(inp: StateTransitionInput) -> bool:
EXITED must be evaluated before any promotion.
"""
return (
inp.hard_stop_hit
or inp.trailing_stop_hit
or inp.model_exit_signal
or inp.be_lock_threat
)
return inp.hard_stop_hit or inp.trailing_stop_hit or inp.model_exit_signal or inp.be_lock_threat
def promote_state(current: PositionState, inp: StateTransitionInput) -> PositionState:

View File

@@ -124,12 +124,14 @@ class PreMarketPlanner:
# 4. Parse response
playbook = self._parse_response(
decision.rationale, today, market, candidates, cross_market,
decision.rationale,
today,
market,
candidates,
cross_market,
current_holdings=current_holdings,
)
playbook_with_tokens = playbook.model_copy(
update={"token_count": decision.token_count}
)
playbook_with_tokens = playbook.model_copy(update={"token_count": decision.token_count})
logger.info(
"Generated playbook for %s: %d stocks, %d scenarios, %d tokens",
market,
@@ -146,7 +148,9 @@ class PreMarketPlanner:
return self._empty_playbook(today, market)
def build_cross_market_context(
self, target_market: str, today: date | None = None,
self,
target_market: str,
today: date | None = None,
) -> CrossMarketContext | None:
"""Build cross-market context from the other market's L6 data.
@@ -192,7 +196,9 @@ class PreMarketPlanner:
)
def build_self_market_scorecard(
self, market: str, today: date | None = None,
self,
market: str,
today: date | None = None,
) -> dict[str, Any] | None:
"""Build previous-day scorecard for the same market."""
if today is None:
@@ -320,18 +326,18 @@ class PreMarketPlanner:
f"{context_text}\n"
f"## Instructions\n"
f"Return a JSON object with this exact structure:\n"
f'{{\n'
f"{{\n"
f' "market_outlook": "bullish|neutral_to_bullish|neutral'
f'|neutral_to_bearish|bearish",\n'
f' "global_rules": [\n'
f' {{"condition": "portfolio_pnl_pct < -2.0",'
f' "action": "REDUCE_ALL", "rationale": "..."}}\n'
f' ],\n'
f" ],\n"
f' "stocks": [\n'
f' {{\n'
f" {{\n"
f' "stock_code": "...",\n'
f' "scenarios": [\n'
f' {{\n'
f" {{\n"
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0,'
f' "unrealized_pnl_pct_above": 3.0, "holding_days_above": 5}},\n'
f' "action": "BUY|SELL|HOLD",\n'
@@ -340,11 +346,11 @@ class PreMarketPlanner:
f' "stop_loss_pct": -2.0,\n'
f' "take_profit_pct": 3.0,\n'
f' "rationale": "..."\n'
f' }}\n'
f' ]\n'
f' }}\n'
f' ]\n'
f'}}\n\n'
f" }}\n"
f" ]\n"
f" }}\n"
f" ]\n"
f"}}\n\n"
f"Rules:\n"
f"- Max {max_scenarios} scenarios per stock\n"
f"- Candidates list is the primary source for BUY candidates\n"
@@ -575,8 +581,7 @@ class PreMarketPlanner:
stop_loss_pct=-3.0,
take_profit_pct=5.0,
rationale=(
f"Rule-based BUY: oversold signal, "
f"RSI={c.rsi:.0f} (fallback planner)"
f"Rule-based BUY: oversold signal, RSI={c.rsi:.0f} (fallback planner)"
),
)
)

View File

@@ -107,7 +107,9 @@ class ScenarioEngine:
# 2. Find stock playbook
stock_pb = playbook.get_stock_playbook(stock_code)
if stock_pb is None:
logger.debug("No playbook for %s — defaulting to %s", stock_code, playbook.default_action)
logger.debug(
"No playbook for %s — defaulting to %s", stock_code, playbook.default_action
)
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
@@ -135,7 +137,9 @@ class ScenarioEngine:
)
# 4. No match — default action
logger.debug("No scenario matched for %s — defaulting to %s", stock_code, playbook.default_action)
logger.debug(
"No scenario matched for %s — defaulting to %s", stock_code, playbook.default_action
)
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
@@ -198,17 +202,27 @@ class ScenarioEngine:
checks.append(price is not None and price < condition.price_below)
price_change_pct = self._safe_float(market_data.get("price_change_pct"))
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
if (
condition.price_change_pct_above is not None
or condition.price_change_pct_below is not None
):
if "price_change_pct" not in market_data:
self._warn_missing_key("price_change_pct")
if condition.price_change_pct_above is not None:
checks.append(price_change_pct is not None and price_change_pct > condition.price_change_pct_above)
checks.append(
price_change_pct is not None and price_change_pct > condition.price_change_pct_above
)
if condition.price_change_pct_below is not None:
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
checks.append(
price_change_pct is not None and price_change_pct < condition.price_change_pct_below
)
# Position-aware conditions
unrealized_pnl_pct = self._safe_float(market_data.get("unrealized_pnl_pct"))
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
if (
condition.unrealized_pnl_pct_above is not None
or condition.unrealized_pnl_pct_below is not None
):
if "unrealized_pnl_pct" not in market_data:
self._warn_missing_key("unrealized_pnl_pct")
if condition.unrealized_pnl_pct_above is not None:
@@ -227,15 +241,9 @@ class ScenarioEngine:
if "holding_days" not in market_data:
self._warn_missing_key("holding_days")
if condition.holding_days_above is not None:
checks.append(
holding_days is not None
and holding_days > condition.holding_days_above
)
checks.append(holding_days is not None and holding_days > condition.holding_days_above)
if condition.holding_days_below is not None:
checks.append(
holding_days is not None
and holding_days < condition.holding_days_below
)
checks.append(holding_days is not None and holding_days < condition.holding_days_below)
return len(checks) > 0 and all(checks)
@@ -295,9 +303,15 @@ class ScenarioEngine:
details["volume_ratio"] = self._safe_float(market_data.get("volume_ratio"))
if condition.price_above is not None or condition.price_below is not None:
details["current_price"] = self._safe_float(market_data.get("current_price"))
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
if (
condition.price_change_pct_above is not None
or condition.price_change_pct_below is not None
):
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
if (
condition.unrealized_pnl_pct_above is not None
or condition.unrealized_pnl_pct_below is not None
):
details["unrealized_pnl_pct"] = self._safe_float(market_data.get("unrealized_pnl_pct"))
if condition.holding_days_above is not None or condition.holding_days_below is not None:
details["holding_days"] = self._safe_float(market_data.get("holding_days"))