Compare commits

...

57 Commits

Author SHA1 Message Date
kjh2064 f40da64a05 fix(web): support runId in Detail page from both route parameters and query strings
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 24s
Prepare Release / Build & Create Release (push) Successful in 56s
Prepare Release / Release Notification (push) Successful in 1s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m8s
2026-07-12 13:37:33 +09:00
kjh2064 475950be36 feat(web): add manual trigger action support for Hangfire recurring jobs
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 22s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
Prepare Release / Build & Create Release (push) Successful in 1m9s
Prepare Release / Release Notification (push) Successful in 2s
2026-07-12 13:36:52 +09:00
kjh2064 27a56e65b7 feat(web): update AppVersion format to comply with quant_YYYYMMDD.count.hash rule
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 23s
Prepare Release / Build & Create Release (push) Successful in 58s
Prepare Release / Release Notification (push) Successful in 1s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 1m52s
2026-07-12 13:31:46 +09:00
kjh2064 a9986fbc4c feat(web): implement progressive IP lockout duration scaling based on block counts
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 22s
Prepare Release / Build & Create Release (push) Successful in 59s
Prepare Release / Release Notification (push) Successful in 1s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m1s
2026-07-12 13:26:30 +09:00
kjh2064 ee14f5fbe4 feat(web): dynamically fetch release version from AssemblyInformationalVersion
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 24s
Prepare Release / Build & Create Release (push) Successful in 59s
Prepare Release / Release Notification (push) Successful in 1s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 1m58s
2026-07-12 13:21:27 +09:00
kjh2064 3f45fbc36e feat(web): display application version on login and admin dashboard pages
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 27s
Prepare Release / Build & Create Release (push) Successful in 1m5s
Prepare Release / Release Notification (push) Successful in 1s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-12 13:19:47 +09:00
kjh2064 87949f42b5 feat(web): add lineage_events.jsonl structured audit logging for collection runs
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 22s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
Prepare Release / Build & Create Release (push) Successful in 1m14s
Prepare Release / Release Notification (push) Successful in 1s
2026-07-12 13:18:50 +09:00
kjh2064 4eb23a41ca feat(web): implement market-close caching mechanism for collection data
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 22s
Prepare Release / Build & Create Release (push) Successful in 1m1s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
Prepare Release / Release Notification (push) Successful in 1s
2026-07-12 13:17:37 +09:00
kjh2064 6db27fd634 feat(web): implement exponential backoff retry pattern in KisApiClient
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 23s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
Prepare Release / Build & Create Release (push) Successful in 1m3s
Prepare Release / Release Notification (push) Successful in 1s
2026-07-12 13:16:38 +09:00
kjh2064 a3fe301308 security(web): secure Hangfire Dashboard with cookie authentication filter
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 24s
Prepare Release / Build & Create Release (push) Successful in 1m0s
Prepare Release / Release Notification (push) Successful in 1s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 1m58s
2026-07-12 13:14:20 +09:00
kjh2064 bcc9b76212 fix(web): resolve User Edit routing mismatch and apply asp-page Tag Helpers
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 22s
Prepare Release / Build & Create Release (push) Successful in 58s
Prepare Release / Release Notification (push) Successful in 1s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 1m54s
2026-07-12 13:12:24 +09:00
kjh2064 7172de3623 docs: create missing REPORT_GUIDE.md to satisfy COMPLETION_HARNESS_INSTRUCTIONS_V1
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 23s
Prepare Release / Build & Create Release (push) Successful in 54s
Prepare Release / Release Notification (push) Successful in 0s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 1m56s
2026-07-12 13:08:15 +09:00
kjh2064 6582ffc02a chore: deprecate and remove blazor client code and project configurations
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 22s
Prepare Release / Build & Create Release (push) Failing after 58s
Prepare Release / Release Notification (push) Successful in 1s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-12 13:06:57 +09:00
kjh2064 f8ff3a2c46 ci: chain release build into production deploy
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 22s
Prepare Release / Build & Create Release (push) Successful in 1m0s
Prepare Release / Release Notification (push) Successful in 1s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 1m55s
2026-07-12 12:59:27 +09:00
kjh2064 ecc7deed08 ci: deploy production on main pushes
Deploy to Production / Deploy to Production (push) Successful in 5s
Deploy to Production / Health Check & Verification (push) Successful in 6s
Deploy to Production / Deployment Report (push) Successful in 1s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 20s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 1m43s
2026-07-12 12:57:04 +09:00
kjh2064 ee0787683b ci: package only generated report artifacts
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 16s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-12 12:55:42 +09:00
kjh2064 f8d93de299 ci: emit contract-shaped missing-data artifacts
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 1m37s
2026-07-12 12:53:28 +09:00
kjh2064 7db7f74713 fix: treat paired missing packet metrics honestly
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 14s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 1m35s
2026-07-12 12:49:34 +09:00
kjh2064 fbb56400af ci: restore KIS collection credential contract
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 1m35s
2026-07-12 12:45:23 +09:00
kjh2064 744e9d6a7a ci: restore qualitative sell validation workflow
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 16s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 1m23s
2026-07-12 12:43:19 +09:00
kjh2064 521d1c0b3d test: make dotnet evidence tests runner portable
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 1m23s
2026-07-12 12:40:54 +09:00
kjh2064 d49e45b308 ci: install dotnet sdk before core tests
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 1m23s
2026-07-12 12:36:02 +09:00
kjh2064 49dc382a39 fix: remove circular WBS evidence dependency
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 56s
2026-07-12 12:33:46 +09:00
kjh2064 15b9840204 ci: regenerate completed WBS verdicts
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 12:30:44 +09:00
kjh2064 534582972d ci: isolate snapshot admin python dependencies
Snapshot Admin Validation / validate (push) Successful in 13s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 20s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 58s
2026-07-12 12:28:59 +09:00
kjh2064 890ed68a0f fix: restore snapshot admin CI validation
Snapshot Admin Validation / validate (push) Failing after 7s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 19s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 57s
2026-07-12 12:26:55 +09:00
kjh2064 92efa934a2 fix: restore authoritative database connection setting
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 12:22:17 +09:00
kjh2064 cc001fa263 test: verify market time series schema contract
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 12:20:44 +09:00
kjh2064 606664404b chore: protect external runtime connection settings
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 16s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 12:19:34 +09:00
kjh2064 a7f9b27a55 fix: require production database connection environment
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 16s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 12:16:22 +09:00
kjh2064 34991eca2d ci: enforce schema model evidence generation
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 16s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 55s
2026-07-12 12:13:02 +09:00
kjh2064 d833d386d0 feat: add postgres market time series schema
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 55s
2026-07-12 12:11:18 +09:00
kjh2064 daec1a0e1b chore: close verified M0 WBS tasks
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 16s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 53s
2026-07-12 12:10:21 +09:00
kjh2064 a9b8f46187 feat: complete dotnet formula and platform cutover gates
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 16s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 12:08:03 +09:00
kjh2064 eb48b7eb07 feat: add dotnet postgres json cutover gate
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 17s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 56s
2026-07-12 12:01:09 +09:00
kjh2064 187cdf99c0 fix: import logging for JSON seed ingestion
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 11:58:11 +09:00
kjh2064 21da79cb46 feat: ingest canonical JSON seeds into PostgreSQL
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 16s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-12 11:57:35 +09:00
kjh2064 49637f1b02 feat: export PostgreSQL training dataset as JSON
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 17s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 11:55:17 +09:00
kjh2064 e15e4c6e20 feat: connect formula decisions to learning ledger
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 17s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 55s
2026-07-12 11:54:06 +09:00
kjh2064 6e666844f0 feat: add canonical decision learning service
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 17s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-12 11:53:17 +09:00
kjh2064 3f6c21c86e chore: expose normalized learning validation
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 17s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 11:51:54 +09:00
kjh2064 6fb43fb411 test: validate normalized learning store contract
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 15s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-12 11:51:31 +09:00
kjh2064 5d89c6ad02 feat: wire normalized learning store into dotnet
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 16s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 11:48:32 +09:00
kjh2064 1d90580854 feat: add normalized PostgreSQL learning history
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 17s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 56s
2026-07-12 11:44:00 +09:00
kjh2064 c25e3bee7a ci: install pytest for parallel storage checks
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 15s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 54s
2026-07-12 11:39:31 +09:00
kjh2064 6e20a924db fix: make snapshot web smoke test clean-checkout safe
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 12s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 53s
2026-07-12 11:37:18 +09:00
kjh2064 780ccee1fe fix: reuse KIS tokens across concurrent requests
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 11s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 51s
2026-07-12 11:32:13 +09:00
kjh2064 129e2ec2d7 ci: parallelize independent validation jobs
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 11s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 22s
2026-07-12 11:14:58 +09:00
kjh2064 4cd1cab466 ci: export isolated Python dependencies
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 20s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Has been skipped
2026-07-12 11:12:19 +09:00
kjh2064 be043a85e3 ci: remove runner venv dependency
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 10s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Has been skipped
2026-07-12 11:11:55 +09:00
kjh2064 1c46d7b558 fix: add admin collection start route
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 8s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Has been skipped
2026-07-12 11:10:15 +09:00
kjh2064 7f0c9b9a27 ci: harden Python environment bootstrap
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 7s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Has been skipped
2026-07-12 11:06:07 +09:00
kjh2064 42d45e85fb ci: validate main pushes automatically
Validators (Pull Requests Only) / validate-core (push) Failing after 8s
Validators (Pull Requests Only) / validate-ui-and-storage (push) Has been skipped
2026-07-12 11:03:52 +09:00
kjh2064 e7d1069222 feat: add quant engine WBS verification harness 2026-07-12 10:58:22 +09:00
kjh2064 a274ef448a fix: Auth cookies invalidated on every deployment (Data Protection discriminator)
User reported: "배포가 되면 인증이 풀린다" (auth resets after every
deployment).

Root cause: Program.cs had no explicit Data Protection configuration.
Without SetApplicationName, ASP.NET Core derives the key-ring
discriminator from the app's physical content root path. Every
deployment lands in a brand-new directory
(~/deployments/quantengine_{tag}_{hash}/), so the discriminator
changed on every single release. The cookie authentication ticket is
encrypted/signed via this key ring, so once the discriminator
changed, every previously-issued auth cookie became undecryptable --
forcing all logged-in users to authenticate again after each deploy,
even well inside their 12-hour ExpireTimeSpan.

Fix: explicit .SetApplicationName("QuantEngine") pins a stable
discriminator across deployments, and .PersistKeysToFileSystem points
at %LOCALAPPDATA%/quantengine-keys (Linux: ~/.local/share/quantengine-keys
via User=kjh2064 in the systemd unit) -- a location outside the
versioned deployment directories, so the actual key material also
survives every redeploy and service restart instead of only the
discriminator being stable.
2026-07-12 02:04:03 +09:00
kjh2064 7283532c38 fix: Hangfire recurring-job initialization failing every startup
Discovered while verifying the new Operations page against a local
instance (SSH-tunneled to prod Postgres): every startup logged
'Hangfire setup failed: Cannot resolve scoped service
QuantEngine.Web.Services.SchedulerService from root provider' and
silently skipped InitializeSchedules() entirely.

SchedulerService is registered AddScoped, but UseHangfireSetup()
resolved it directly from app.Services (the root/singleton-level
provider), which cannot construct scoped services without an active
scope. This has apparently been broken for a while -- the 4 recurring
jobs (daily-collection, hourly-price-update, weekly-report,
monthly-optimization) only kept showing up because Hangfire persists
recurring job definitions in PostgreSQL from whatever earlier
deployment last managed to register them; any code change to those
schedules would silently never take effect on redeploy.

Fixed by creating an explicit scope (serviceProvider.CreateScope())
before resolving SchedulerService. Verified locally: the warning is
gone and the log now shows "Hangfire schedules initialized
successfully" followed by the dispatchers starting.
2026-07-12 01:57:08 +09:00
kjh2064 489da25f1b fix: Remove fake hardcoded data, rebuild admin layout with real Tabler components
Root cause: user asked why logout was missing. Playwright audit against
production found logout works fine, but surfaced two real defects and
led to a wider audit that found extensive fabricated data across the
admin pages -- none of it backed by the database despite CLAUDE.md's
policy that all data must come from DB records.

Layout (_AdminLayout.cshtml):
- Full rewrite using Tabler's actual navbar-vertical/page-wrapper/footer
  component structure instead of ad-hoc inline CSS. The old layout had
  no footer element at all, and its mobile breakpoint CSS hid the
  sidebar off-screen (left: -260px) with no hamburger button to bring
  it back -- verified via Playwright screenshot at 375px width that
  the entire nav menu was inaccessible on mobile, leaving only Logout
  reachable. Tabler's navbar-toggler + Bootstrap collapse (bundled in
  tabler.min.js) now restores it; verified the toggle actually opens
  the menu via Playwright.
- Active nav-link highlighting moved from client-side JS string
  matching to a server-side Razor helper against Context.Request.Path.

Fake/hardcoded data removed or replaced with real DB/Hangfire state:
- Dashboard: deleted the "최근 시스템 이벤트" table (3 rows hardcoded
  from DateTime.Now with fake descriptions like "시스템 초기화" /
  "데이터베이스 백업" -- no backing table exists). Removed hardcoded
  "정상"/"연결됨" status badges and "버전: v0.1.0"/"업타임: 정상";
  replaced with a real IsDatabaseConnected flag (true only if the
  page's actual DB queries succeeded) and the real
  IWebHostEnvironment.EnvironmentName.
- Monitoring: removed hardcoded "API 서버: 운영 중" (no real signal
  backs it) and wired "데이터베이스: 연결 정상/끊김" to the same
  real success/failure state as the page's own DB calls.
- Operations: this page was entirely fabricated -- ScheduledJobs,
  RecentExecutions, IsJobProcessorRunning, PendingJobsCount, and
  StatusMessage were all static values with zero connection to
  Hangfire, despite Hangfire actually running in production
  (confirmed via journalctl: ServerWatchdog, RecurringJobScheduler
  dispatchers active) with 4 real recurring jobs registered in
  SchedulerService (daily-collection, hourly-price-update,
  weekly-report, monthly-optimization). Rewrote to query
  JobStorage.Current.GetConnection().GetRecurringJobs() and
  GetMonitoringApi() directly: real scheduled jobs, real succeeded/
  failed executions, real server count, real enqueued count. Verified
  locally (SSH-tunneled to prod DB) that this now returns the actual
  4 registered jobs with correct next-run times and one real
  RunDailyCollectionAsync execution.

Also fixed the page-title duplication on Monitoring/Operations
(ViewData["Title"] included "- QuantEngine" AND the layout appended
it again -> "모니터링 - QuantEngine - QuantEngine" in the browser tab).

Separately discovered (not fixed in this commit, flagging for
follow-up): Hangfire's SchedulerService.InitializeSchedules() fails
every startup with "Cannot resolve scoped service 'SchedulerService'
from root provider" -- the 4 recurring jobs above still show up
because they persist from an earlier successful registration, but
re-registration is silently broken on every current boot.

Verified end-to-end with Playwright against a local instance (SSH
tunnel to production Postgres): login, all 5 admin pages render
without errors, mobile hamburger opens the sidebar, and Operations
shows genuine Hangfire data.
2026-07-12 01:54:00 +09:00
133 changed files with 6226 additions and 4202 deletions
+53 -69
View File
@@ -1,13 +1,17 @@
name: Validators (Pull Requests Only) name: Validators (Pushes and Pull Requests)
on: on:
pull_request: pull_request:
branches: [ main ] branches: [ main ]
push:
branches: [ main ]
workflow_dispatch: workflow_dispatch:
# Phase 3: Validator pipeline # Validator pipeline. Independent validation jobs run in parallel.
# Main branch push validation moved to merge-to-main.yml
# This workflow runs validators only on PRs for feedback concurrency:
group: quantengine-ci-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
validate-core: validate-core:
@@ -32,38 +36,15 @@ jobs:
- name: Setup Python Environment - name: Setup Python Environment
run: | run: |
# 순수 Python 패키지만 설치 (numpy/pandas 제외 — ARMv7l 휠 없음) # 순수 Python 패키지만 설치 (numpy/pandas 제외 — ARMv7l 휠 없음)
VENV_BASE=$HOME/python_venv PYTHON_DEPS="$HOME/python_deps/$(md5sum tools/validate_specs.py | cut -d' ' -f1)"
REQ_HASH=$(md5sum tools/validate_specs.py 2>/dev/null | cut -d' ' -f1 || echo "default") mkdir -p "$PYTHON_DEPS"
VENV="$VENV_BASE/$REQ_HASH" /usr/bin/python3 --version
/usr/bin/python3 -m pip --version
if [ ! -f "$VENV/bin/python" ]; then /usr/bin/python3 -m pip install --disable-pip-version-check --quiet \
echo "=== venv 신규 생성: $REQ_HASH ===" --target "$PYTHON_DEPS" requests pyyaml openpyxl pytest
mkdir -p "$VENV_BASE" export PYTHONPATH="$PYTHON_DEPS:${PYTHONPATH:-}"
/usr/bin/python3 -m venv "$VENV" echo "PYTHONPATH=$PYTHON_DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
/usr/bin/python3 -c 'import requests, yaml, openpyxl, pytest; print("Python dependencies: PASS")'
# venv 내 pip 확인 및 복구
if [ ! -f "$VENV/bin/pip" ]; then
echo "pip missing in venv, installing via get-pip.py..."
curl -sS https://bootstrap.pypa.io/pip/3.8/get-pip.py -o get-pip.py
"$VENV/bin/python" get-pip.py --quiet
rm get-pip.py
fi
"$VENV/bin/pip" install --upgrade pip --quiet
"$VENV/bin/pip" install requests pyyaml openpyxl --quiet
# 오래된 venv 정리 (최근 2개만 유지)
ls -dt "$VENV_BASE"/*/ 2>/dev/null | tail -n +3 | xargs rm -rf 2>/dev/null || true
else
echo "=== venv 캐시 히트: $("$VENV/bin/python" --version 2>&1) ==="
"$VENV/bin/python" - <<'PY'
import importlib
for mod in ("requests", "yaml", "openpyxl"):
importlib.import_module(mod)
print("venv dependency import check: PASS")
PY
fi
echo "$VENV/bin" >> $GITHUB_PATH
- name: Install Node Dependencies - name: Install Node Dependencies
run: | run: |
@@ -86,7 +67,7 @@ jobs:
fi fi
else else
echo "=== npm install (최초 or lock 변경) ===" echo "=== npm install (최초 or lock 변경) ==="
npm install --quiet npm ci --quiet
# 캐시 저장 # 캐시 저장
mkdir -p "$CACHE_DIR" mkdir -p "$CACHE_DIR"
cp -r node_modules "$CACHE_DIR/node_modules" cp -r node_modules "$CACHE_DIR/node_modules"
@@ -120,6 +101,29 @@ jobs:
- name: Validate Platform Transition WBS - name: Validate Platform Transition WBS
run: python3 tools/validate_platform_transition_wbs_v1.py run: python3 tools/validate_platform_transition_wbs_v1.py
- name: Validate Schema Model Generation
run: python3 tools/generate_schema_model_generation_evidence_v1.py && python3 tools/validate_schema_model_generation_v1.py
- name: Validate Market Time Series Schema
run: python3 tools/validate_market_time_series_schema_v1.py
- name: Generate DONE WBS Verdicts
run: |
for task in QE-M0-01 QE-M0-02 QE-M0-03 QE-M0-04 QE-M0-05 QE-M0-06; do
python3 tools/verify_wbs_task_v1.py --task "$task"
done
- name: Validate Quant Engine WBS
run: python3 tools/validate_quant_engine_wbs_v1.py
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Run .NET Unit Tests
run: dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --nologo
- name: Build Calibration Priority Backlog - name: Build Calibration Priority Backlog
run: python3 tools/build_calibration_priority_v1.py run: python3 tools/build_calibration_priority_v1.py
@@ -168,8 +172,9 @@ jobs:
- name: Ensure Temp Directory and Mock Packet - name: Ensure Temp Directory and Mock Packet
run: | run: |
mkdir -p Temp mkdir -p Temp
python3 -c 'import json; json.dump({"order_blueprint_json":{},"cash_recovery_plan_json":{},"per_ticker":[{"ticker":"DATA_MISSING","gate":"DATA_MISSING"}],"meta":{"formulas_run":[],"source_file":"GatherTradingData.json"}},open("Temp/computed_harness_v1.json","w"),ensure_ascii=False,indent=2)'
if [ ! -f Temp/final_decision_packet_active.json ]; then if [ ! -f Temp/final_decision_packet_active.json ]; then
echo '{"formula_id":"FINAL_DECISION_PACKET_V2","meta":{"generated_at":"2026-06-29T00:00:00Z"},"canonical_metrics":{},"portfolio_snapshot":{},"order_table":[]}' > Temp/final_decision_packet_active.json python3 -c 'import json; json.dump({"formula_id":"FINAL_DECISION_PACKET_V2","meta":{"generated_at":"2026-06-29T00:00:00Z"},"canonical_metrics":{"total_asset_krw":None},"portfolio_snapshot":{},"order_table":[],"pass_100":{"gate":"DATA_MISSING","score_0_100":None},"execution_readiness":{"gate":"DATA_MISSING","min_axis_score":None},"prediction":{"match_rate_pct":None}},open("Temp/final_decision_packet_active.json","w"),ensure_ascii=False,indent=2)'
fi fi
- name: Validate Replay Live Separation - name: Validate Replay Live Separation
@@ -197,7 +202,7 @@ jobs:
run: python3 tools/validate_postgresql_history_contract_v1.py run: python3 tools/validate_postgresql_history_contract_v1.py
- name: Package Operational Report Artifacts - name: Package Operational Report Artifacts
run: tar -czf Temp/operational-report-artifacts.tar.gz Temp/operational_report.json Temp/operational_report.md Temp/missing_data_inventory_v1.json Temp/report_section_completeness.json Temp/operational_alpha_calibration_v2.json Temp/validate_operational_alpha_calibration_v2.json Temp/operational_t20_outcome_ledger_v1.json Temp/live_data_activation_gate_v1.json Temp/replay_live_separation_v1.json Temp/validate_report_packet_sync_v1.json Temp/json_generator_outputs_v1.json Temp/proposal_evaluation_history.json Temp/performance_readiness_replay_bridge_v1.json Temp/postgresql_history_schema_v1.sql Temp/postgresql_history_schema_v1.json Temp/postgresql_history_contract_v1.json run: tar -czf Temp/operational-report-artifacts.tar.gz Temp/operational_report.json Temp/missing_data_inventory_v1.json Temp/report_section_completeness.json Temp/operational_alpha_calibration_v2.json Temp/validate_operational_alpha_calibration_v2.json Temp/operational_t20_outcome_ledger_v1.json Temp/live_data_activation_gate_v1.json Temp/replay_live_separation_v1.json Temp/validate_report_packet_sync_v1.json Temp/json_generator_outputs_v1.json Temp/proposal_evaluation_history.json Temp/performance_readiness_replay_bridge_v1.json Temp/postgresql_history_schema_v1.sql Temp/postgresql_history_schema_v1.json Temp/postgresql_history_contract_v1.json
- name: Upload Operational Report Artifacts - name: Upload Operational Report Artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -213,8 +218,6 @@ jobs:
validate-ui-and-storage: validate-ui-and-storage:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: validate-core
if: github.event_name != 'push'
steps: steps:
- name: Checkout Code - name: Checkout Code
@@ -224,47 +227,28 @@ jobs:
- name: Setup Python Environment - name: Setup Python Environment
run: | run: |
VENV_BASE=$HOME/python_venv PYTHON_DEPS="$HOME/python_deps/$(md5sum tools/validate_snapshot_admin_web_v1.py | cut -d' ' -f1)"
REQ_HASH=$(md5sum tools/validate_snapshot_admin_web_v1.py 2>/dev/null | cut -d' ' -f1 || echo "default") mkdir -p "$PYTHON_DEPS"
VENV="$VENV_BASE/$REQ_HASH" /usr/bin/python3 --version
/usr/bin/python3 -m pip --version
if [ ! -f "$VENV/bin/python" ]; then /usr/bin/python3 -m pip install --disable-pip-version-check --quiet \
echo "=== venv 신규 생성: $REQ_HASH ===" --target "$PYTHON_DEPS" requests pyyaml openpyxl pytest
mkdir -p "$VENV_BASE" export PYTHONPATH="$PYTHON_DEPS:${PYTHONPATH:-}"
/usr/bin/python3 -m venv "$VENV" echo "PYTHONPATH=$PYTHON_DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
/usr/bin/python3 -c 'import requests, yaml, openpyxl, pytest; print("Python dependencies: PASS")'
if [ ! -f "$VENV/bin/pip" ]; then
echo "pip missing in venv, installing via get-pip.py..."
curl -sS https://bootstrap.pypa.io/pip/3.8/get-pip.py -o get-pip.py
"$VENV/bin/python" get-pip.py --quiet
rm get-pip.py
fi
"$VENV/bin/pip" install --upgrade pip --quiet
"$VENV/bin/pip" install requests pyyaml openpyxl --quiet
else
echo "=== venv 캐시 히트: $("$VENV/bin/python" --version 2>&1) ==="
fi
echo "$VENV/bin" >> $GITHUB_PATH
- name: Validate Snapshot Admin Web UI - name: Validate Snapshot Admin Web UI
if: needs.validate-core.result == 'success'
run: python3 tools/validate_snapshot_admin_web_v1.py run: python3 tools/validate_snapshot_admin_web_v1.py
- name: Validate Storage Backend Contracts - name: Validate Storage Backend Contracts
if: needs.validate-core.result == 'success'
run: python3 -m pytest tests/unit/test_storage_backend_v1.py tests/unit/test_validate_kis_api_credentials_v1.py tests/unit/test_qualitative_sell_strategy_store_v1.py tests/unit/test_kis_api_client_v1.py tests/unit/test_snapshot_admin_store_v1.py tests/unit/test_snapshot_admin_web_v1.py -q run: python3 -m pytest tests/unit/test_storage_backend_v1.py tests/unit/test_validate_kis_api_credentials_v1.py tests/unit/test_qualitative_sell_strategy_store_v1.py tests/unit/test_kis_api_client_v1.py tests/unit/test_snapshot_admin_store_v1.py tests/unit/test_snapshot_admin_web_v1.py -q
- name: Notify PR Result - name: Notify PR Result
if: always() && github.event_name == 'pull_request' if: always() && github.event_name == 'pull_request'
env: env:
CORE_RESULT: ${{ needs.validate-core.result }}
STAGE_RESULT: ${{ job.status }} STAGE_RESULT: ${{ job.status }}
run: | run: |
STATUS="$STAGE_RESULT" STATUS="$STAGE_RESULT"
if [ "$CORE_RESULT" != "success" ]; then
STATUS="failure"
fi
PR_NUM="${{ github.event.pull_request.number }}" PR_NUM="${{ github.event.pull_request.number }}"
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "$STATUS" = "success" ]; then if [ "$STATUS" = "success" ]; then
+5 -5
View File
@@ -1,6 +1,9 @@
name: Deploy to Production name: Deploy to Production
on: on:
workflow_run:
workflows: [Prepare Release]
types: [completed]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release: release:
@@ -22,6 +25,7 @@ env:
jobs: jobs:
deploy: deploy:
name: Deploy to Production name: Deploy to Production
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
outputs: outputs:
@@ -204,12 +208,8 @@ jobs:
echo "ERROR: QuantEngine.Web.dll not found" echo "ERROR: QuantEngine.Web.dll not found"
exit 1 exit 1
fi fi
if [ ! -f "$DEPLOY_DIR/appsettings.Production.json" ]; then
echo "ERROR: appsettings.Production.json not found"
exit 1
fi
echo "✓ DLL verified" echo "✓ DLL verified"
echo "✓ Config verified" echo "✓ Runtime configuration is managed outside the release artifact"
# 3. Update Symlink # 3. Update Symlink
echo "" echo ""
+21
View File
@@ -0,0 +1,21 @@
name: KIS Data Collection Validation
on:
schedule:
- cron: "30 0 * * 1-5"
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Validate mock credentials
env:
KIS_APP_Key_TEST: ${{ vars.KIS_APP_KEY_TEST }}
KIS_APP_Secret_TEST: ${{ vars.KIS_APP_SECRET_TEST }}
KIS_APP_Key: ${{ vars.KIS_APP_KEY }}
KIS_APP_Secret: ${{ vars.KIS_APP_SECRET }}
run: python3 tools/validate_kis_api_credentials_v1.py --account mock --ticker 005930 --dry-run
- name: Validate .NET PostgreSQL JSON cutover
run: python3 tools/validate_dotnet_postgresql_json_cutover_v1.py
+2
View File
@@ -1,6 +1,8 @@
name: Prepare Release name: Prepare Release
on: on:
push:
branches: [main]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
@@ -0,0 +1,24 @@
name: Qualitative Sell Strategy Validation
on:
schedule:
- cron: "15 0 * * 1-5"
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Python dependencies
run: |
DEPS="$RUNNER_TEMP/quantengine_sell_deps"
python3 -m pip install --disable-pip-version-check --quiet --target "$DEPS" pyyaml
echo "PYTHONPATH=$DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
- name: Validate mock credentials
env:
KIS_APP_Key_TEST: ${{ vars.KIS_APP_KEY_TEST }}
KIS_APP_Secret_TEST: ${{ vars.KIS_APP_SECRET_TEST }}
run: python3 tools/validate_kis_api_credentials_v1.py --account mock --ticker 005930 --dry-run
- name: Validate qualitative sell pipeline
run: python3 tools/validate_qualitative_sell_strategy_pipeline_v1.py
+25
View File
@@ -0,0 +1,25 @@
name: Snapshot Admin Validation
on:
push:
paths:
- "src/quant_engine/snapshot_admin_*.py"
- "tools/validate_snapshot_admin_*.py"
- "tests/unit/test_snapshot_admin_*.py"
- ".gitea/workflows/snapshot_admin.yml"
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Python dependencies
run: |
PYTHON_DEPS="$RUNNER_TEMP/quantengine_snapshot_admin_deps"
python3 -m pip install --disable-pip-version-check --quiet --target "$PYTHON_DEPS" pyyaml pytest
echo "PYTHONPATH=$PYTHON_DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
- name: Validate snapshot admin workflow
run: python3 tools/validate_snapshot_admin_workflow_v1.py
- name: Run snapshot admin tests
run: python3 -m pytest tests/unit/test_snapshot_admin_store_v1.py tests/unit/test_snapshot_admin_web_v1.py -q
+6
View File
@@ -1,5 +1,11 @@
# 은퇴자산포트폴리오 투자 에이전트 운영 지침 # 은퇴자산포트폴리오 투자 에이전트 운영 지침
## QuantEngine 운영 설정 권위
- `ConnectionStrings__DefaultConnection`은 운영 설정에서 관리한다.
- 저장소 코드, DbUp migration, CI artifact는 운영 계정 비밀번호를 생성하거나 덮어쓰지 않는다. 단, 명시된 운영 설정 복원 작업은 예외로 한다.
- 배포/검증 하네스는 설정값을 읽기만 하며, 값 자체를 로그·증빙·커밋에 기록하지 않는다.
- 설정 변경은 애플리케이션 배포와 분리된 운영 설정 변경으로 취급한다. 설정 복원 시에는 Git 이력의 마지막 권위값만 사용한다.
## 0. 최우선 원칙 ## 0. 최우선 원칙
- 이 파일은 운영 인덱스다. 상세 규칙은 `governance/rules/*.yaml``spec/*.yaml`를 우선한다. - 이 파일은 운영 인덱스다. 상세 규칙은 `governance/rules/*.yaml``spec/*.yaml`를 우선한다.
- 가격, 수량, TP/SL, 점수는 오직 `spec/13_formula_registry.yaml`와 하네스 산출값만 사용한다. - 가격, 수량, TP/SL, 점수는 오직 `spec/13_formula_registry.yaml`와 하네스 산출값만 사용한다.
+3
View File
@@ -688,3 +688,6 @@ See `docs/GITEA_ACTIONS_API_GUIDE.md` for complete API reference.
- **Newtonsoft.Json**: Known high-severity vulnerability (GHSA-5crp-9r3c-p9vr); update or replace when feasible - **Newtonsoft.Json**: Known high-severity vulnerability (GHSA-5crp-9r3c-p9vr); update or replace when feasible
- **Release Authority**: Python gates (`full-gate`, `prepare-upload-zip`) remain authority; .NET Admin fully operational as of 2026-07-11 - **Release Authority**: Python gates (`full-gate`, `prepare-upload-zip`) remain authority; .NET Admin fully operational as of 2026-07-11
- **Testing Requirement**: All code changes must pass local testing with SSH tunnel to remote DB before deployment (see "Local Development & Testing" above) - **Testing Requirement**: All code changes must pass local testing with SSH tunnel to remote DB before deployment (see "Local Development & Testing" above)
- **DBML Schema Sync (2026-07-12)**: DbUp 마이그레이션(`src/dotnet/QuantEngine.Infrastructure/Migrations/V*.sql`)으로 관리되는 모든 테이블은 **반드시 `docs/db/quantengine.dbml`에도 동기화**되어야 하며, 개발 시 스키마 참조는 이 DBML 파일을 기준으로 한다. 새 마이그레이션 추가 시 같은 커밋에서 DBML 갱신 필수.
- **Diagrams**: 상태전이/플로우차트/시퀀스 다이어그램은 Mermaid로 `docs/diagrams/`에 작성해 코딩 참조로 활용 (수집 파이프라인: `docs/diagrams/collection-pipeline.md`)
- **WBS Evidence Gate (2026-07-12)**: 퀀트 엔진 로드맵/WBS는 `spec/60_quant_engine_wbs.yaml`(기계 판정)로 관리. 작업 완료는 `npm run verify:task -- <TASK_ID>` 게이트 PASS로만 인정 (BE=PG쿼리/로그/JSON, FE=Playwright+스크린샷). 전체 게이트: `npm run verify:wbs`
+13
View File
@@ -0,0 +1,13 @@
# Report Guide (보고서 지침)
본 문서는 은퇴자산 포트폴리오 투자 에이전트의 보고 및 작업 완료 기준을 정의합니다.
## 기본 완료 조건 (Default Completion Harness)
모든 작업은 아래의 4가지 요소가 모두 충족되어 검증을 통과해야 완료로 판정합니다.
1. **YAML 계약/공식**: 계약, 공식 및 거버넌스 파일(`yaml`)의 원본 권위가 변경 사항에 맞게 최신화되어야 합니다.
2. **코드 구현**: `code` 구현이 `src/` 또는 `tools/`에 명확히 반영되어야 합니다.
3. **데이터 실체**: 수집 및 계산 결과가 담긴 데이터 실체(`data artifact` 또는 `data/artifact`)가 디렉토리에 정상적으로 생성되고 확인되어야 합니다.
4. **검증 증빙**: 재현 가능한 테스트 실행 및 검증 명령의 결과 파일 또는 터미널 출력이 `validation evidence`(`검증 증빙`)로 기록되어야 합니다.
이러한 완료 프로세스는 `completion harness`를 통해 엄격하게 통제됩니다.
+14
View File
@@ -2271,3 +2271,17 @@ python tools/validate_snapshot_admin_web_v1.py
> 이 문서는 `docs/ROADMAP_WBS.md` 에 저장됩니다. > 이 문서는 `docs/ROADMAP_WBS.md` 에 저장됩니다.
> 스프린트 완료마다 **완성도 KPI 섹션**을 업데이트하세요. > 스프린트 완료마다 **완성도 KPI 섹션**을 업데이트하세요.
> 모든 WBS 항목의 구현 시 반드시 **하네스 성공 기준**을 먼저 충족 후 다음 단계로 진행합니다. > 모든 WBS 항목의 구현 시 반드시 **하네스 성공 기준**을 먼저 충족 후 다음 단계로 진행합니다.
---
## 차세대 퀀트 엔진 로드맵/WBS 포인터 (2026-07-12)
이후의 퀀트 엔진 진화 로드맵(M0–M5: 실증 하네스 → 수집 배선 → 시계열 저장소 →
실데이터 팩터 → 백테스팅 → 포트폴리오/레짐)과 상세 WBS는 **기계 판정 YAML**로 관리한다:
- **스펙(단일 진실 원천)**: `spec/60_quant_engine_wbs.yaml` (formula_id: `QUANT_ENGINE_WBS_V1`)
- **단일 작업 검증**: `python tools/verify_wbs_task_v1.py --task <TASK_ID>``Temp/evidence/<TASK_ID>/verdict.json`
- **전체 WBS 게이트**: `python tools/validate_quant_engine_wbs_v1.py``Temp/quant_engine_wbs_v1.json`
완료 판정 원칙: 작업은 게이트 실행(PASS)으로만 `DONE` 이 될 수 있다.
BE = PostgreSQL 쿼리 + Serilog 로그 패턴 + JSON 아티팩트, FE = Playwright(DOM assert + API 기대값 대조 + 스크린샷).
+516
View File
@@ -0,0 +1,516 @@
// =============================================================================
// QuantEngine Database Schema (DBML)
// DbUp 마이그레이션(V1~V5)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신
// (CLAUDE.md 규칙: schema 변경 → DBML + 문서 동기화)
//
// 참고: Hangfire 스키마는 Hangfire.PostgreSql 라이브러리가 자동 생성
// (DbUp 마이그레이션으로 관리하지 않음, 여기서도 제외)
// =============================================================================
Project quantengine {
database_type: 'PostgreSQL'
Note: '''
QuantEngine v0.1 데이터베이스 스키마.
세 개 스키마로 구성:
- quantengine: 핵심 KIS API 토큰, 사용자 계정, 수집 파이프라인 데이터
- engine_history: 팩터 계산 이력, 시장 데이터 이력, 의사결정 이력
- (생략) hangfire: Hangfire 백그라운드 잡 관리 (auto-created)
'''
}
// =============================================================================
// Schema: quantengine (V1 + V2)
// =============================================================================
TableGroup "quantengine" {
kis_tokens
workspace_account
workspace_session
collection_runs
collection_snapshots
collection_source_errors
settings
account_snapshot
workspace_meta
workspace_change_log
workspace_approval_v2
workspace_lock
kis_collection_runs
kis_collection_snapshots
kis_collection_errors
}
Table quantengine.kis_tokens {
account TEXT [pk, note: "KIS 계정 모드 (real/mock)"]
access_token TEXT [not null, note: "KIS 토큰"]
expires_at TEXT [not null, note: "만료 시각 (ISO 8601)"]
updated_at TEXT [not null, note: "마지막 갱신 시각 (ISO 8601)"]
Note: "KIS Open API 인증 토큰 캐시"
}
Table quantengine.workspace_account {
ordinal INT [not null, note: "순서 인덱스"]
username TEXT [pk, note: "로그인 ID"]
password_hash TEXT [not null, note: "BCrypt 또는 SHA-256 해시 (자동 마이그레이션 가능)"]
role TEXT [not null, default: "'Admin'", note: "역할 (Admin)"]
is_active TEXT [not null, default: "'true'", note: "활성 상태 (true/false)"]
created_at TEXT [not null, note: "생성 시각 (ISO 8601)"]
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
indexes {
(is_active, username) [name: "idx_workspace_account_active"]
}
Note: "Admin UI 사용자 계정"
}
Table quantengine.workspace_session {
session_token_hash TEXT [pk, note: "세션 토큰 해시"]
username TEXT [not null, note: "사용자명"]
role TEXT [not null, default: "'Admin'", note: "역할"]
created_at TEXT [not null, note: "세션 생성 시각 (ISO 8601)"]
expires_at TEXT [not null, note: "만료 시각 (ISO 8601)"]
revoked_at TEXT [note: "취소 시각 (ISO 8601), NULL이면 활성"]
indexes {
(username, expires_at) [name: "idx_workspace_session_username"]
}
Note: "세션 관리 (쿠키 기반 인증)"
}
Table quantengine.collection_runs {
run_id TEXT [pk, note: "수집 실행 ID (예: api-20260712-120000)"]
collector_name TEXT [not null, note: "수집기 이름"]
started_at TEXT [not null, note: "시작 시각 (ISO 8601)"]
finished_at TEXT [note: "종료 시각 (ISO 8601)"]
status TEXT [not null, note: "상태 (RUNNING/COMPLETED/FAILED)"]
input_source TEXT [note: "입력 소스 경로"]
output_json_path TEXT [note: "출력 JSON 파일 경로"]
output_db_path TEXT [note: "출력 DB 경로"]
notes TEXT [note: "메모"]
created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"]
Note: "데이터 수집 실행 기록 (레거시, V2의 kis_collection_runs 참조)"
}
Table quantengine.collection_snapshots {
run_id TEXT [not null, note: "수집 실행 ID"]
dataset_name TEXT [not null, note: "데이터셋명"]
ticker TEXT [not null, note: "종목코드 (예: 005930)"]
name TEXT [note: "종목명"]
sector TEXT [note: "업종"]
as_of_date TEXT [note: "기준 일자"]
source_priority TEXT [note: "소스 우선순위"]
source_status TEXT [note: "소스 상태"]
payload_json TEXT [not null, note: "정규화된 데이터 (JSON)"]
provenance_json TEXT [not null, note: "출처 정보 (JSON)"]
created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"]
indexes {
(run_id, dataset_name, ticker) [pk]
(ticker, created_at) [name: "idx_collection_snapshots_ticker_time"]
}
Note: "수집 스냅샷 (레거시, V2의 kis_collection_snapshots 참조)"
}
Table quantengine.collection_source_errors {
run_id TEXT [not null, note: "수집 실행 ID"]
ticker TEXT [note: "종목코드"]
source_name TEXT [not null, note: "소스명"]
error_kind TEXT [not null, note: "에러 타입"]
error_message TEXT [not null, note: "에러 메시지"]
payload_json TEXT [note: "에러 상세 (JSON)"]
created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"]
indexes {
(run_id, source_name) [name: "idx_collection_source_errors_run"]
}
Note: "수집 중 발생한 에러 기록 (레거시)"
}
Table quantengine.settings {
ordinal INT [not null, note: "순서 인덱스"]
key TEXT [pk, note: "설정 키"]
value_json TEXT [not null, note: "값 (JSON)"]
note TEXT [not null, default: "''", note: "설명"]
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
Note: "애플리케이션 설정 저장소"
}
Table quantengine.account_snapshot {
ordinal INT [not null, note: "순서 인덱스"]
row_json TEXT [not null, note: "계정 데이터 (JSON)"]
captured_at TEXT [not null, default: "''", note: "캡처 시각 (ISO 8601)"]
account TEXT [not null, default: "''", note: "계정"]
account_type TEXT [not null, default: "''", note: "계정 타입"]
ticker TEXT [not null, default: "''", note: "종목코드"]
name TEXT [not null, default: "''", note: "이름"]
parse_status TEXT [not null, default: "''", note: "파싱 상태"]
user_confirmed TEXT [not null, default: "''", note: "사용자 확인 여부"]
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
indexes {
(captured_at) [name: "idx_account_snapshot_captured_at"]
(ticker) [name: "idx_account_snapshot_ticker"]
}
Note: "계정 스냅샷 저장소"
}
Table quantengine.workspace_meta {
key TEXT [pk, note: "메타 키"]
value_json TEXT [not null, note: "값 (JSON)"]
Note: "워크스페이스 메타데이터"
}
Table quantengine.workspace_change_log {
id SERIAL [pk, note: "자동 증가 ID"]
domain TEXT [not null, note: "도메인"]
action TEXT [not null, note: "액션 (create/update/delete)"]
target_ref TEXT [not null, default: "''", note: "대상 참조"]
actor TEXT [not null, default: "'system'", note: "액터 (사용자/시스템)"]
note TEXT [not null, default: "''", note: "메모"]
before_json TEXT [not null, default: "'null'", note: "변경 전 값 (JSON)"]
after_json TEXT [not null, default: "'null'", note: "변경 후 값 (JSON)"]
created_at TEXT [not null, note: "기록 시각 (ISO 8601)"]
Note: "변경 로그"
}
Table quantengine.workspace_approval_v2 {
domain TEXT [not null, note: "도메인"]
target_ref TEXT [not null, default: "'*'", note: "대상 참조"]
status TEXT [not null, note: "승인 상태"]
approved_by TEXT [not null, default: "''", note: "승인자"]
approved_at TEXT [not null, default: "''", note: "승인 시각 (ISO 8601)"]
note TEXT [not null, default: "''", note: "메모"]
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
indexes {
(domain, target_ref) [pk]
}
Note: "승인 워크플로우"
}
Table quantengine.workspace_lock {
domain TEXT [not null, note: "도메인"]
target_ref TEXT [not null, default: "''", note: "대상 참조"]
locked_by TEXT [not null, default: "''", note: "잠금 사용자"]
reason TEXT [not null, default: "''", note: "잠금 사유"]
locked_at TEXT [not null, note: "잠금 시각 (ISO 8601)"]
indexes {
(domain, target_ref) [pk]
}
Note: "동시성 제어용 잠금"
}
// =============================================================================
// V2: KIS 수집 파이프라인 (kis_collection_*)
// =============================================================================
Table quantengine.kis_collection_runs {
run_id TEXT [pk, note: "수집 실행 ID"]
status TEXT [not null, note: "상태: RUNNING / COMPLETED / COMPLETED_WITH_ERRORS / FAILED"]
started_at TEXT [not null, note: "시작 시각 (ISO 8601 KST)"]
finished_at TEXT [note: "종료 시각 (ISO 8601 KST)"]
total_snapshots INTEGER [note: "성공한 스냅샷 수"]
total_errors INTEGER [note: "발생한 에러 수"]
updated_at TEXT [not null, note: "마지막 갱신 시각 (ISO 8601)"]
indexes {
(started_at) [name: "idx_kis_runs_started_at"]
}
Note: "KIS API 수집 실행 기록"
}
Table quantengine.kis_collection_snapshots {
run_id TEXT [not null, note: "수집 실행 ID"]
dataset_name TEXT [note: "데이터셋명 (예: data_feed)"]
ticker TEXT [not null, note: "종목코드 (예: 005930)"]
source_name TEXT [not null, note: "데이터 소스 (kis_open_api 등)"]
payload_json TEXT [not null, note: "정규화된 수집 데이터 (JSON)"]
captured_at TEXT [not null, note: "캡처 시각 (ISO 8601 KST)"]
created_at TEXT [not null, note: "DB 기록 시각 (ISO 8601)"]
indexes {
(run_id, ticker, source_name) [pk]
(ticker) [name: "idx_kis_snapshots_ticker"]
(captured_at) [name: "idx_kis_snapshots_captured_at"]
}
Note: "KIS API 수집 스냅샷 (시계열 데이터)"
}
Table quantengine.kis_collection_errors {
id SERIAL [pk, note: "자동 증가 ID"]
run_id TEXT [not null, note: "수집 실행 ID"]
source_name TEXT [not null, note: "데이터 소스"]
error_kind TEXT [not null, note: "에러 타입 (예: HttpRequestException)"]
error_message TEXT [note: "에러 메시지"]
ticker TEXT [note: "종목코드 (해당하면)"]
created_at TEXT [not null, note: "DB 기록 시각 (ISO 8601)"]
indexes {
(run_id) [name: "idx_kis_errors_run_id"]
}
Note: "KIS API 수집 중 발생한 에러"
}
// =============================================================================
// Schema: engine_history (V3)
// =============================================================================
TableGroup "engine_history" {
market_raw_history
factor_version_history
factor_output_history
decision_result_history
market_vs_engine_gap_history
source_observation
factor_definition
factor_observation
decision_event
decision_factor_evidence
outcome_evaluation
}
Table engine_history.market_raw_history {
id BIGSERIAL [pk, note: "자동 증가 ID"]
source_id TEXT [not null, note: "소스 ID"]
observed_at TEXT [not null, note: "관측 시각 (ISO 8601)"]
source_name TEXT [not null, note: "소스명 (kis_open_api 등)"]
instrument_id TEXT [not null, note: "상품 ID (종목코드 등)"]
field_name TEXT [not null, note: "필드명 (현재가, 종가 등)"]
field_value TEXT [not null, note: "필드값 (문자열)"]
unit TEXT [not null, note: "단위 (원, % 등)"]
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
indexes {
(created_at) [name: "idx_market_raw_history_created_at"]
}
Note: "시장 데이터 원본 이력 (정규화 전)"
}
Table engine_history.factor_version_history {
id BIGSERIAL [pk, note: "자동 증가 ID"]
factor_id TEXT [not null, note: "팩터 ID (예: momentum_ss001)"]
factor_version TEXT [not null, note: "팩터 버전 (예: v1.0.0)"]
effective_from TEXT [not null, note: "유효 시작 일자 (YYYYMMDD)"]
effective_to TEXT [not null, note: "유효 종료 일자 (YYYYMMDD)"]
formula_id TEXT [not null, note: "계산식 ID"]
source_version TEXT [not null, note: "소스 버전"]
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
indexes {
(created_at) [name: "idx_factor_version_history_created_at"]
}
Note: "팩터 버전 관리 이력"
}
Table engine_history.factor_output_history {
id BIGSERIAL [pk, note: "자동 증가 ID"]
factor_output_id TEXT [not null, note: "팩터 출력 ID"]
observed_at TEXT [not null, note: "관측 일자 (YYYYMMDD)"]
factor_id TEXT [not null, note: "팩터 ID"]
factor_version TEXT [not null, note: "팩터 버전"]
output_value TEXT [not null, note: "출력값 (문자열)"]
output_gate TEXT [not null, note: "게이트 (PASS/FAIL/WARN)"]
source_version TEXT [not null, note: "소스 버전"]
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
indexes {
(created_at) [name: "idx_factor_output_history_created_at"]
}
Note: "팩터 계산 결과 이력"
}
Table engine_history.decision_result_history {
id BIGSERIAL [pk, note: "자동 증가 ID"]
decision_id TEXT [not null, note: "의사결정 ID"]
decided_at TEXT [not null, note: "의사결정 일자 (YYYYMMDD)"]
instrument_id TEXT [not null, note: "상품 ID (종목코드 등)"]
action TEXT [not null, note: "액션 (BUY/SELL/HOLD)"]
gate TEXT [not null, note: "게이트 (PASS/FAIL)"]
score TEXT [not null, note: "스코어 (문자열)"]
source_version TEXT [not null, note: "소스 버전"]
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
indexes {
(created_at) [name: "idx_decision_result_history_created_at"]
}
Note: "의사결정 결과 이력"
}
Table engine_history.market_vs_engine_gap_history {
id BIGSERIAL [pk, note: "자동 증가 ID"]
gap_id TEXT [not null, note: "갭 ID"]
observed_at TEXT [not null, note: "관측 일자 (YYYYMMDD)"]
instrument_id TEXT [not null, note: "상품 ID"]
metric_name TEXT [not null, note: "지표명"]
market_value TEXT [not null, note: "시장값"]
engine_value TEXT [not null, note: "엔진값"]
gap_value TEXT [not null, note: "갭값 (절대값)"]
gap_pct TEXT [not null, note: "갭 백분율 (%)"]
source_version TEXT [not null, note: "소스 버전"]
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
indexes {
(created_at) [name: "idx_market_vs_engine_gap_history_created_at"]
}
Note: "시장 데이터 vs 엔진 계산 갭 분석 이력"
}
// =============================================================================
// Schema: engine_history (V5 normalized learning history)
// =============================================================================
Table quantengine.price_history_daily {
ticker TEXT [not null]
trade_date DATE [not null]
open NUMERIC [not null]
high NUMERIC [not null]
low NUMERIC [not null]
close NUMERIC [not null]
volume BIGINT [not null]
source TEXT [not null]
collected_at TIMESTAMPTZ [not null, default: "NOW()"]
provenance JSONB [not null, default: "'{}'::jsonb"]
indexes {
(ticker, trade_date) [pk]
}
}
Table quantengine.macro_history_daily {
symbol TEXT [not null]
trade_date DATE [not null]
value NUMERIC [not null]
source TEXT [not null]
collected_at TIMESTAMPTZ [not null, default: "NOW()"]
provenance JSONB [not null, default: "'{}'::jsonb"]
indexes {
(symbol, trade_date) [pk]
}
}
Table engine_history.source_observation {
observation_id UUID [pk]
observed_at TIMESTAMPTZ [not null]
instrument_id TEXT [not null]
source_name TEXT [not null]
source_version TEXT [not null]
payload JSONB [not null]
provenance JSONB [not null, default: "'{}'::jsonb"]
created_at TIMESTAMPTZ [not null, default: "NOW()"]
}
Table engine_history.factor_definition {
factor_id TEXT [not null]
factor_version TEXT [not null]
formula_id TEXT [not null]
effective_from TIMESTAMPTZ [not null]
effective_to TIMESTAMPTZ
definition JSONB [not null, default: "'{}'::jsonb"]
provenance JSONB [not null, default: "'{}'::jsonb"]
indexes {
(factor_id, factor_version) [pk]
}
}
Table engine_history.factor_observation {
factor_observation_id UUID [pk]
observation_id UUID [not null]
factor_id TEXT [not null]
factor_version TEXT [not null]
observed_at TIMESTAMPTZ [not null]
numeric_value NUMERIC
text_value TEXT
gate TEXT [not null]
provenance JSONB [not null, default: "'{}'::jsonb"]
}
Table engine_history.decision_event {
decision_id UUID [pk]
decision_key TEXT [not null, unique]
decided_at TIMESTAMPTZ [not null]
instrument_id TEXT [not null]
action TEXT [not null]
gate TEXT [not null]
score NUMERIC
source_version TEXT [not null]
trace JSONB [not null, default: "'{}'::jsonb"]
provenance JSONB [not null, default: "'{}'::jsonb"]
created_at TIMESTAMPTZ [not null, default: "NOW()"]
}
Table engine_history.decision_factor_evidence {
decision_id UUID [not null]
factor_observation_id UUID [not null]
role TEXT [not null]
indexes {
(decision_id, factor_observation_id) [pk]
}
}
Table engine_history.outcome_evaluation {
evaluation_id UUID [pk]
decision_id UUID [not null]
horizon_days INT [not null]
evaluated_at TIMESTAMPTZ [not null]
realized_return NUMERIC
benchmark_return NUMERIC
excess_return NUMERIC
outcome_class TEXT [not null]
evaluation_gate TEXT [not null]
provenance JSONB [not null, default: "'{}'::jsonb"]
indexes {
(decision_id, horizon_days) [unique]
}
}
// =============================================================================
// Relationships (Logical, not enforced as FKs in DDL)
// =============================================================================
Ref: quantengine.kis_collection_snapshots.run_id > quantengine.kis_collection_runs.run_id {
// logical relationship: snapshots belong to a run
}
Ref: quantengine.kis_collection_errors.run_id > quantengine.kis_collection_runs.run_id {
// logical relationship: errors belong to a run
}
Ref: quantengine.workspace_session.username > quantengine.workspace_account.username {
// logical relationship: session belongs to a user
}
Ref: engine_history.factor_observation.observation_id > engine_history.source_observation.observation_id
Ref: engine_history.factor_observation.(factor_id, factor_version) > engine_history.factor_definition.(factor_id, factor_version)
Ref: engine_history.decision_factor_evidence.decision_id > engine_history.decision_event.decision_id
Ref: engine_history.decision_factor_evidence.factor_observation_id > engine_history.factor_observation.factor_observation_id
Ref: engine_history.outcome_evaluation.decision_id > engine_history.decision_event.decision_id
+167
View File
@@ -0,0 +1,167 @@
# QuantEngine 수집 파이프라인 (KIS API)
## 1. 수집 실행 상태 전이도 (State Diagram)
KIS 데이터 수집 실행(kis_collection_runs)의 상태 흐름. 상태값은 KisDataCollectionOrchestrator 에서 정의:
- `RUNNING`: 수집 진행 중
- `COMPLETED`: 모든 스냅샷 수집 완료 (에러 없음, `total_errors == 0`)
- `COMPLETED_WITH_ERRORS`: 부분 수집 완료 (에러 발생, `total_errors > 0`이지만 일부 성공)
- `FAILED`: 전체 실패 (예외 발생, 데이터 미적재)
```mermaid
stateDiagram-v2
[*] --> RUNNING: 수집 시작<br/>(RunCollectionAsync)
RUNNING --> COMPLETED: 완료 & error_count==0
RUNNING --> COMPLETED_WITH_ERRORS: 완료 & error_count>0
RUNNING --> FAILED: 예외 발생
COMPLETED --> [*]
COMPLETED_WITH_ERRORS --> [*]
FAILED --> [*]
```
**상태 전이 조건** (KisDataCollectionOrchestrator.cs 라인 104-105):
- `error_count == 0``COMPLETED`
- `error_count > 0``COMPLETED_WITH_ERRORS`
- 예외(Exception) → `FAILED`
**성공 기준** (CLAUDE.md "Collection Run Success Criteria"):
- Success: `status == "COMPLETED"` (NOT failed)
- Partial Success: `status == "COMPLETED"` + `total_snapshots > 0` + `total_errors > 0`
- Failure: `status == "FAILED"` OR `total_snapshots == 0`
---
## 2. 수집 파이프라인 흐름도 (Flowchart)
KIS API 데이터 수집의 전체 흐름. 두 개의 트리거:
1. **Hangfire 정기 작업**: 매일 09:00 에 자동 실행
2. **API 수동 트리거**: POST /api/collection/run (쿠키 기반 인증)
```mermaid
flowchart TD
A["Hangfire daily-collection<br/>(09:00 KST)"]
B["POST /api/collection/run<br/>(Cookie Auth)"]
A --> C["IServiceScopeFactory.CreateScope<br/>(resolve ICollectionOrchestrator)"]
B --> C
C --> D["KisDataCollectionOrchestrator.RunCollectionAsync<br/>(tickers: [005930, 000660, ...])"]
D --> E["Per-ticker 루프"]
E --> F["KisApiPriceSource.GetPriceDataAsync<br/>(ticker, account)"]
F --> G["PriceDataNormalizer.NormalizeCollectionRow<br/>(seedRow, kisResult)"]
G --> H["CollectionRepository.SaveSnapshot<br/>(kis_collection_snapshots)"]
G --> I["CollectionRepository.SaveError<br/>(kis_collection_errors, on exception)"]
H --> J{루프 끝?}
I --> J
J -->|Yes| K["CollectionRepository.SaveRun<br/>(kis_collection_runs)"]
J -->|No| E
K --> L["파일 출력:<br/>Temp/kis_dotnet_collection_v1.json"]
L --> M["Serilog 로그:<br/>src/dotnet/.../logs/"]
M --> N["Admin UI: /Admin/Collection<br/>(CollectionRepository 읽기)"]
N --> O["대시보드 표시:<br/>상태, 스냅샷 수, 에러"]
```
**데이터 흐름**:
1. **입력**: Hangfire 스케줄 or API 수동 요청
2. **오케스트레이션**: ICollectionOrchestrator 스코프 생성
3. **수집**: KIS Open API 호출 → PriceDataNormalizer → DB 저장
4. **출력**:
- kis_collection_runs: 실행 메타데이터 (run_id, status, total_snapshots, total_errors)
- kis_collection_snapshots: 종목별 가격 데이터 (JSON payload)
- kis_collection_errors: 에러 기록
- Temp/kis_dotnet_collection_v1.json: 수집 결과 요약 (formula_id, gate, run_id, summary)
- Serilog 로그: 런타임 로그 (src/dotnet/QuantEngine.Web/logs/)
5. **표시**: Admin UI에서 CollectionRepository API 호출 → kis_collection_* 읽기 → Dashboard 렌더링
---
## 3. WBS 증거 검증 시퀀스도 (Sequence Diagram)
작업 완료 증거를 자동 검증하는 파이프라인. 도구: `verify_wbs_task_v1.py` (증거 수집) + `validate_quant_engine_wbs_v1.py` (CI에서 재검증).
```mermaid
sequenceDiagram
Developer->>verify_wbs_task_v1.py: python verify_wbs_task_v1.py --task QE-M1-01<br/>(또는 --run-commands)
verify_wbs_task_v1.py->>+spec/60_quant_engine_wbs.yaml: load spec
spec/60_quant_engine_wbs.yaml-->>-verify_wbs_task_v1.py: meta + tasks[QE-M1-01]
Note over verify_wbs_task_v1.py: evidence_checks 선언형 해석
alt pg_query 체크
verify_wbs_task_v1.py->>+PostgreSQL: SELECT ... (WHERE 절)
PostgreSQL-->>-verify_wbs_task_v1.py: 스칼라 결과 또는 행
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: expect{min,max,equals} 비교
end
alt log_pattern 체크
verify_wbs_task_v1.py->>+src/dotnet/.../logs/: file_glob 매칭
src/dotnet/.../logs/-->>-verify_wbs_task_v1.py: 로그 라인
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 정규식 패턴 검사<br/>(min_matches, max_age_hours)
end
alt json_gate 체크
verify_wbs_task_v1.py->>+Temp/kis_dotnet_collection_v1.json: read JSON
Temp/kis_dotnet_collection_v1.json-->>-verify_wbs_task_v1.py: payload
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 점 표기 경로(dot notation)<br/>+ 값 비교 (>=N 지원)
end
alt file_exists 체크
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: paths[] 존재 확인<br/>(min_bytes 검증)
end
alt playwright_report 체크
verify_wbs_task_v1.py->>+tests/e2e/playwright-report.json: read report
tests/e2e/playwright-report.json-->>-verify_wbs_task_v1.py: suites[].specs[]
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: spec_file 매칭<br/>(passed_min, failed)
end
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 모든 체크 결과 종합<br/>(gate = ALL PASS? → PASS : FAIL)
verify_wbs_task_v1.py->>+Temp/evidence/QE-M1-01/: mkdir
verify_wbs_task_v1.py->>Temp/evidence/QE-M1-01/verdict.json: write verdict<br/>(task_id, gate, checks[])
verify_wbs_task_v1.py->>Temp/evidence/QE-M1-01/: save raw evidence<br/>(pg_query_n.json, log_excerpt.txt, ...)
verify_wbs_task_v1.py->>+runtime/lineage_events.jsonl: append event<br/>(node_id, gate, timestamp)
Developer<<--verify_wbs_task_v1.py: exit 0 (gate=PASS)<br/>or exit 1 (gate=FAIL)
Note over Developer: 선택: --run-commands 플래그<br/>verification_commands[] 실행
Developer->>+validate_quant_engine_wbs_v1.py: (CI) python validate_quant_engine_wbs_v1.py
validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: spec load
validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: tasks[status==DONE] 필터
validate_quant_engine_wbs_v1.py->>+Temp/evidence/*/verdict.json: load all verdicts
Temp/evidence/*/verdict.json-->>-validate_quant_engine_wbs_v1.py: gate 값
validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: gate=FAIL? → CI FAIL
validate_quant_engine_wbs_v1.py->>+Temp/quant_engine_wbs_v1.json: write summary
Developer<<--validate_quant_engine_wbs_v1.py: exit 0 (모두 PASS)<br/>or exit 1 (일부 FAIL)
```
**검증 프로세스 상세**:
| 단계 | 역할 | 산출물 |
|------|------|--------|
| **1. 스펙 로드** | verify_wbs_task_v1.py | spec/60_quant_engine_wbs.yaml |
| **2. 증거 체크 실행** | 선언형 evidence_checks[] | pg_query / log_pattern / json_gate / file_exists / playwright_report |
| **3. 게이트 결정** | 모든 체크 PASS? | gate = PASS or FAIL |
| **4. 증거 저장** | Temp/evidence/<TASK_ID>/ | verdict.json + 원시 증거 |
| **5. 계보 로깅** | runtime/lineage_events.jsonl | node_id, gate, timestamp |
| **6. CI 재검증** | validate_quant_engine_wbs_v1.py | status=DONE 작업만 재검증 |
**주요 특징**:
- **선언형 검증**: 체크 로직을 YAML에 기술 (하드코딩 최소화)
- **원시 증거 보존**: 각 체크의 상세 결과를 JSON/텍스트로 저장
- **완료 주장 차단**: "완료했다"는 수동 선언 불가 → verdict.json gate=PASS만 인정
- **CI 편입**: validate_quant_engine_wbs_v1.py가 release DAG의 노드로 동작
- **멀티 트리거**: 단일 작업 검증 (--task) 또는 전체 검증 (CI)
**검증 체크 타입 참고** (spec/60_quant_engine_wbs.yaml "evidence_check_types"):
- **pg_query**: PostgreSQL 스칼라 결과 비교 (min/max/equals)
- **log_pattern**: 로그 파일 정규식 매칭 (min_matches, max_age_hours)
- **json_gate**: JSON 아티팩트 키-값 검사 (점 표기 경로, >=N 비교)
- **file_exists**: 파일 존재 + 크기 검증 (min_bytes)
- **playwright_report**: Playwright 리포트 테스트 결과 (passed_min, failed)
+11 -1
View File
@@ -52,7 +52,17 @@
"validate-engine-strict": "python tools/run_release_dag_v3.py --mode release --strict", "validate-engine-strict": "python tools/run_release_dag_v3.py --mode release --strict",
"validate-behavioral-coverage": "python tools/validate_behavioral_coverage_v1.py --strict", "validate-behavioral-coverage": "python tools/validate_behavioral_coverage_v1.py --strict",
"validate-engine-integrity": "python tools/run_release_dag_v3.py --mode release --strict", "validate-engine-integrity": "python tools/run_release_dag_v3.py --mode release --strict",
"render-report-json": "dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json" "render-report-json": "dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json",
"verify:task": "python tools/verify_wbs_task_v1.py --task",
"collect:remote-evidence": "python tools/collect_remote_wbs_evidence_v1.py",
"verify:wbs": "python tools/validate_quant_engine_wbs_v1.py",
"validate:normalized-learning-store": "python tools/validate_normalized_learning_store_v1.py",
"validate:dotnet-cutover": "python tools/validate_dotnet_postgresql_json_cutover_v1.py",
"validate:schema-model": "python tools/generate_schema_model_generation_evidence_v1.py && python tools/validate_schema_model_generation_v1.py",
"validate:runtime-settings": "python tools/validate_runtime_connection_settings_immutability_v1.py",
"validate:market-schema": "python tools/validate_market_time_series_schema_v1.py",
"test:e2e": "playwright test --project=chromium",
"test:evidence": "playwright test --project=evidence"
}, },
"dependencies": { "dependencies": {
"cheerio": "1.2.0", "cheerio": "1.2.0",
+8 -1
View File
@@ -5,6 +5,7 @@ import { defineConfig, devices } from '@playwright/test';
*/ */
export default defineConfig({ export default defineConfig({
testDir: './tests/e2e', testDir: './tests/e2e',
testIgnore: '**/archive/**',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: true, fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -14,7 +15,7 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'list', reporter: [['list'], ['json', { outputFile: 'Temp/evidence/playwright-last-run.json' }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
@@ -29,8 +30,14 @@ export default defineConfig({
projects: [ projects: [
{ {
name: 'chromium', name: 'chromium',
testIgnore: ['**/archive/**', '**/evidence/**'],
use: { ...devices['Desktop Chrome'] }, use: { ...devices['Desktop Chrome'] },
}, },
{
name: 'evidence',
testDir: './tests/e2e/evidence',
use: { ...devices['Desktop Chrome'], screenshot: 'on', trace: 'on' },
},
], ],
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
+5 -1
View File
@@ -176,7 +176,9 @@ quant_feed_contract:
database_first_operating_model: database_first_operating_model:
purpose: "운영 이력, 원천 팩터, 파생 최종 팩터, 시장-결과 괴리를 PostgreSQL에 누적해 엔진을 고도화한다." purpose: "운영 이력, 원천 팩터, 파생 최종 팩터, 시장-결과 괴리를 PostgreSQL에 누적해 엔진을 고도화한다."
canonical_store: canonical_store:
primary: "PostgreSQL" primary: "PostgreSQL"
canonical_engine_history_migration: "src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql"
learning_projection: "engine_history.training_example_v1"
secondary: "SQLite transient cache only" secondary: "SQLite transient cache only"
prohibited_operating_path: prohibited_operating_path:
- "Excel workbook as operational source" - "Excel workbook as operational source"
@@ -190,6 +192,8 @@ quant_feed_contract:
policy: policy:
- "최종 팩터와 최종 판단은 DB 이력 테이블에 버전과 시각을 함께 남긴다." - "최종 팩터와 최종 판단은 DB 이력 테이블에 버전과 시각을 함께 남긴다."
- "시장 raw와 엔진 결과의 괴리는 별도 gap history로 적재한다." - "시장 raw와 엔진 결과의 괴리는 별도 gap history로 적재한다."
- "원천 관측·factor·decision·outcome은 정규화된 PostgreSQL event store에 적재한다."
- "학습/캘리브레이션 입력은 training_example_v1 역정규화 view에서만 생성한다."
- "엑셀/시트/Apps Script는 더 이상 운영 경로가 아니라, 역사적 import/export 또는 폐기 대상만 허용한다." - "엑셀/시트/Apps Script는 더 이상 운영 경로가 아니라, 역사적 import/export 또는 폐기 대상만 허용한다."
- "새 분석·리포트는 PostgreSQL snapshot을 1차 진실원천으로 사용한다." - "새 분석·리포트는 PostgreSQL snapshot을 1차 진실원천으로 사용한다."
xlsx_analysis_protocol: xlsx_analysis_protocol:
+18
View File
@@ -2280,6 +2280,23 @@ dag:
strict: false strict: false
timeout_sec: 60 timeout_sec: 60
warn_only: true warn_only: true
validate_quant_engine_wbs:
artifact_policy: keep
cache_key: validate_quant_engine_wbs_v1
command:
- python
- tools/validate_quant_engine_wbs_v1.py
depends_on: []
id: validate_quant_engine_wbs
inputs:
- tools/validate_quant_engine_wbs_v1.py
- spec/60_quant_engine_wbs.yaml
note: 퀀트 엔진 WBS 증거 게이트 — status=DONE 작업은 Temp/evidence/<TASK_ID>/verdict.json
gate=PASS 가 있어야 한다 (완료 주장 금지, 게이트 실행으로만 DONE).
outputs:
- Temp/quant_engine_wbs_v1.json
strict: true
timeout_sec: 60
validate_specs: validate_specs:
artifact_policy: keep artifact_policy: keep
cache_key: validate_specs_v1 cache_key: validate_specs_v1
@@ -2327,6 +2344,7 @@ execution_order:
- validate_metric_alias_collision - validate_metric_alias_collision
- validate_packaged_refs - validate_packaged_refs
- validate_property_invariants - validate_property_invariants
- validate_quant_engine_wbs
- validate_renderer_no_calc - validate_renderer_no_calc
- validate_runtime_source_whitelist - validate_runtime_source_whitelist
- validate_sector_universe_monthly_refresh - validate_sector_universe_monthly_refresh
+715
View File
@@ -0,0 +1,715 @@
# =============================================================================
# QuantEngine 데이터 실증 기반 퀀트 엔진 로드맵 + WBS (기계 판정)
# =============================================================================
# formula_id: QUANT_ENGINE_WBS_V1
# 원칙: 모든 작업(task)은 "완료 주장"이 아니라 게이트 실행으로만 DONE 판정된다.
# - BE 실증: pg_query(PostgreSQL 쿼리) + log_pattern(Serilog 로그) + json_gate(아티팩트)
# - FE 실증: playwright_report(스펙 PASS) + file_exists(스크린샷)
# 실행:
# 단일 작업 검증: python tools/verify_wbs_task_v1.py --task <TASK_ID>
# → Temp/evidence/<TASK_ID>/verdict.json + 원시 증거 보존
# 전체 WBS 게이트: python tools/validate_quant_engine_wbs_v1.py
# → Temp/quant_engine_wbs_v1.json (status=DONE 작업의 증거 재검증)
# 관례: spec/16_data_gaps_roadmap.yaml 의 success_criteria 구조
# (expected_success_value / evidence_artifacts / verification_commands) 준수.
# 검증 로직만 하드코딩 → evidence_checks 선언형으로 일반화.
# =============================================================================
meta:
formula_id: QUANT_ENGINE_WBS_V1
version: 1
created: "2026-07-12"
authority: "governance/authority_matrix.yaml"
validator: tools/validate_quant_engine_wbs_v1.py
task_verifier: tools/verify_wbs_task_v1.py
remote_evidence_collector: tools/collect_remote_wbs_evidence_v1.py
evidence_root: Temp/evidence
status_values: [PENDING, IN_PROGRESS, DONE] # DONE = 해당 verdict.json gate=PASS 필수
db_connection:
# 검증기의 PostgreSQL 접속 순서:
# 1) env QE_WBS_PG_DSN (psycopg DSN)
# 2) env ConnectionStrings__DefaultConnection (.NET 형식 → 자동 변환)
# 3) src/dotnet/QuantEngine.Web/appsettings.Development.json 의 ConnectionStrings.DefaultConnection
# (로컬은 SSH 터널 127.0.0.1:5432 전제 — CLAUDE.md "Local Development & Testing")
dotnet_appsettings: src/dotnet/QuantEngine.Web/appsettings.Development.json
remote_evidence:
collector: "python tools/collect_remote_wbs_evidence_v1.py --target <ssh-target>"
policy: "Collect journal and JSON artifacts only; never copy env files or passwords."
postgres: "Use QE_WBS_PG_DSN through an approved SSH tunnel; do not embed credentials in evidence."
# -----------------------------------------------------------------------------
# 검증 체크 타입 사전 (verify_wbs_task_v1.py 가 해석하는 선언형 vocabulary)
# -----------------------------------------------------------------------------
evidence_check_types:
pg_query: "PostgreSQL 쿼리 1개 실행, 단일 스칼라 결과를 expect{min,max,equals}와 비교. 원시 결과를 pg_query_<n>.json 으로 보존"
log_pattern: "file_glob 로그 파일들에서 정규식 매칭. expect{min_matches, max_age_hours(파일 mtime 기준)}. 매칭 라인을 log_excerpt.txt 로 보존"
json_gate: "path 의 JSON 아티팩트에서 expect 의 키-값 검사 (점 표기 경로 지원, 값 '>=N' 비교 지원)"
file_exists: "paths 의 모든 파일 존재 (expect.min_bytes 선택)"
playwright_report: "Playwright JSON 리포트(report)에서 spec_file 의 결과가 expect{passed_min, failed} 충족"
# =============================================================================
# 로드맵 (M0 → M5)
# =============================================================================
roadmap:
scope_note: >
전통 팩터(모멘텀/거래량/수급/실적/매크로/밸류/재무건전성 = spec/08_scoring_rules.yaml SS001)
+ ATR 리스크 관리 기본 포함. 최신 기법은 레짐 감지 + 워크포워드 캘리브레이션 + 거래비용
반영 평가로 한정(사용자 확정, 2026-07-12). 딥러닝/인트라데이/대체데이터/실거래 집행 제외
(은퇴자산 + read-only KIS 거버넌스: governance/rules/06_no_direct_api_trading.yaml 유지).
phases:
M0:
name: "실증 하네스 + 정직성 정리"
goal: "완료 주장이 불가능한 구조 확립 — 검증기/증거 규약/CI 편입 + 가짜 검증 제거"
exit_gate: "validate_quant_engine_wbs 가 release DAG/CI 노드로 PASS; dotnet test + Playwright evidence 스위트 CI 편입; 디버그 스펙 격리·가짜 PASS 제거"
tasks: [QE-M0-01, QE-M0-02, QE-M0-03, QE-M0-04, QE-M0-05, QE-M0-06]
M1:
name: "수집 파이프라인 배선"
goal: "운영 앱이 실제 KIS 데이터를 수집하도록 고아 오케스트레이터 배선 (첫 실데이터 실증)"
exit_gate: "Hangfire daily-collection + POST /api/collection/run 으로 kis_collection_* 에 실데이터 적재, Admin Collection 페이지 Playwright 실증"
tasks: [QE-M1-01, QE-M1-02, QE-M1-03, QE-M1-04, QE-M1-05]
M2:
name: "히스토리 시계열 저장소"
goal: "모멘텀 팩터·백테스트의 전제인 일봉/매크로 시계열 축적 (2년 백필)"
exit_gate: "price_history_daily/macro_history_daily 에 유니버스 2년치; (ticker,date) 중복 0; 거래일 캘린더 대비 gap 0"
tasks: [QE-M2-01, QE-M2-02, QE-M2-03, QE-M2-04, QE-M2-05]
M3:
name: "실데이터 팩터 계산"
goal: "SS001 전통 팩터를 PG 히스토리에서 계산해 engine_history 에 적재, 파일 개수 골든커버리지를 수치 패리티로 대체"
exit_gate: "factor_output_history 에 유니버스 전체 스코어(0-100); Python 참조 대비 패리티 ≥20 formula tol 1e-9 PASS"
tasks: [QE-M3-01, QE-M3-02, QE-M3-03, QE-M3-04, QE-M3-05]
M4:
name: "백테스팅 + 검증"
goal: "point-in-time 데이터만 사용하는 워크포워드 백테스터 + 거래비용 모델 + no-lookahead 게이트 실배선"
exit_gate: "Sharpe/MDD/턴오버 JSON 산출; no-lookahead 정상 PASS + 오염 픽스처 FAIL 양방향; T+5/T+20 원장 표본 ≥30"
tasks: [QE-M4-01, QE-M4-02, QE-M4-03, QE-M4-04, QE-M4-05]
M5:
name: "포트폴리오 구성 + 최신 기법"
goal: "레짐 감지 + SS001 가중치 워크포워드 캘리브레이션(제약+shrinkage) + 변동성 타게팅 사이징"
exit_gate: "백필 전 기간 레짐 라벨; 캘리브레이션 가중치 제약 준수 + OOS Sharpe 정직 보고; 최종 포트폴리오 패킷 캡 준수"
tasks: [QE-M5-01, QE-M5-02, QE-M5-03, QE-M5-04]
# =============================================================================
# WBS 작업 목록
# =============================================================================
tasks:
# ---------------------------------------------------------------------------
# M0 — 실증 하네스 + 정직성 정리
# ---------------------------------------------------------------------------
QE-M0-01:
title: "WBS 스펙(YAML) + 로드맵 작성, 레거시 로드맵 문서에 포인터 추가"
status: DONE
depends_on: []
owner_files:
- spec/60_quant_engine_wbs.yaml
- docs/ROADMAP_WBS.md
success_criteria:
expected_success_value: { spec_exists: true, legacy_pointer_appended: true }
evidence_artifacts: [Temp/evidence/QE-M0-01/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-01"]
evidence_checks:
- type: file_exists
paths: [spec/60_quant_engine_wbs.yaml]
expect: { min_bytes: 10000 }
- type: log_pattern
file_glob: docs/ROADMAP_WBS.md
pattern: "QUANT_ENGINE_WBS_V1"
expect: { min_matches: 1 }
QE-M0-02:
title: "증거 검증기 2종 구현 (단일 작업 verifier + 전체 WBS validator) + 유닛테스트"
status: DONE
depends_on: []
owner_files:
- tools/verify_wbs_task_v1.py
- tools/validate_quant_engine_wbs_v1.py
- tests/unit/test_validate_quant_engine_wbs_v1.py
success_criteria:
expected_success_value: { self_test: PASS, synthetic_pass_fail_bidirectional: true }
evidence_artifacts: [Temp/evidence/QE-M0-02/verdict.json, Temp/quant_engine_wbs_v1.json]
verification_commands:
- "python -m pytest tests/unit/test_validate_quant_engine_wbs_v1.py -q"
- "python tools/verify_wbs_task_v1.py --task QE-M0-02"
evidence_checks:
- type: file_exists
paths:
- tools/verify_wbs_task_v1.py
- tools/validate_quant_engine_wbs_v1.py
- tests/unit/test_validate_quant_engine_wbs_v1.py
- type: log_pattern
file_glob: tools/validate_quant_engine_wbs_v1.py
pattern: "def main"
expect: { min_matches: 1 }
QE-M0-03:
title: "Playwright 정직성 정리 + evidence 프로젝트 + npm 스크립트"
status: DONE
depends_on: [QE-M0-02]
owner_files:
- playwright.config.ts
- package.json
- tests/e2e/archive/
notes: >
디버그 스펙(~17개: debug-login, html-debug, wasm-test, framework-check, console-check,
screenshot-diagnosis, inspect-page, login* 변형 등)을 tests/e2e/archive/ 로 이동하고
testIgnore 로 제외. full-validation.spec.ts 의 assert 없는 가짜 "[PASS]" 배너 테스트
제거(파일째 archive). evidence 프로젝트: testDir tests/e2e/evidence, screenshot 'on',
trace 'on', JSON reporter → Temp/evidence/playwright-last-run.json.
npm 스크립트: verify:task / verify:wbs / test:e2e / test:evidence
success_criteria:
expected_success_value: { default_project_specs: ["admin-pages.spec.ts"], fake_pass_removed: true }
evidence_artifacts: [Temp/evidence/QE-M0-03/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-03"]
evidence_checks:
- type: file_exists
paths: [tests/e2e/archive]
- type: log_pattern
file_glob: playwright.config.ts
pattern: "evidence"
expect: { min_matches: 1 }
- type: log_pattern
file_glob: package.json
pattern: "verify:task"
expect: { min_matches: 1 }
- type: log_pattern
file_glob: tests/e2e/full-validation.spec.ts
pattern: ".*"
expect: { max_matches: 0 } # 파일이 기본 testDir 에 더 이상 존재하지 않아야 함
QE-M0-04:
title: "CI에 dotnet test 편입 + 고아 QuantEngine.Web.Tests 처리"
status: DONE
depends_on: []
owner_files:
- .gitea/workflows/ci.yml
- src/dotnet/QuantEngine.Web.Tests/
notes: >
QuantEngine.Web.Tests/DashboardComponentTests.cs 는 csproj 없는 고아(폐기된 Blazor 대상).
현 Razor Pages UI 에 맞지 않으면 삭제. ci.yml 에 dotnet test 스텝 추가.
success_criteria:
expected_success_value: { ci_has_dotnet_test: true, core_tests_green: true }
evidence_artifacts: [Temp/evidence/QE-M0-04/verdict.json]
verification_commands:
- "dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --nologo"
- "python tools/verify_wbs_task_v1.py --task QE-M0-04"
evidence_checks:
- type: log_pattern
file_glob: .gitea/workflows/ci.yml
pattern: "dotnet test"
expect: { min_matches: 1 }
QE-M0-05:
title: "release DAG + CI 에 validate_quant_engine_wbs 게이트 노드 등록"
status: DONE
depends_on: [QE-M0-02]
owner_files:
- spec/41_release_dag.yaml
- .gitea/workflows/ci.yml
success_criteria:
expected_success_value: { dag_node: validate_quant_engine_wbs, ci_step: true }
evidence_artifacts: [Temp/evidence/QE-M0-05/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-05"]
evidence_checks:
- type: log_pattern
file_glob: spec/41_release_dag.yaml
pattern: "validate_quant_engine_wbs"
expect: { min_matches: 1 }
- type: log_pattern
file_glob: .gitea/workflows/ci.yml
pattern: "validate_quant_engine_wbs_v1"
expect: { min_matches: 1 }
QE-M0-06:
title: "골든커버리지 정직성 표기 (coverage_basis: FILE_COUNT_ONLY)"
status: DONE
depends_on: []
owner_files:
- tools/validate_golden_coverage_100.py
notes: >
골든 테스트 174개는 실행되지 않는 placeholder. 커버리지 판정 출력에
coverage_basis: FILE_COUNT_ONLY 필드를 추가해 실체를 명시(삭제는 M3 패리티 대체 후).
success_criteria:
expected_success_value: { honesty_field: FILE_COUNT_ONLY }
evidence_artifacts: [Temp/evidence/QE-M0-06/verdict.json]
verification_commands:
- "python tools/validate_golden_coverage_100.py"
- "python tools/verify_wbs_task_v1.py --task QE-M0-06"
evidence_checks:
- type: json_gate
path: Temp/golden_coverage_100_v1.json
expect: { coverage_basis: FILE_COUNT_ONLY }
# ---------------------------------------------------------------------------
# M1 — 수집 파이프라인 배선 (첫 실데이터 실증)
# ---------------------------------------------------------------------------
QE-M1-01:
title: "KisDataCollectionOrchestrator DI 등록 + daily-collection Hangfire 잡 실구현"
status: PENDING
depends_on: [QE-M0-02]
owner_files:
- src/dotnet/QuantEngine.Web/Program.cs
- src/dotnet/QuantEngine.Web/Services/SchedulerService.cs
notes: >
Program.cs 에 PriceDataNormalizer / SourcePriorityResolver / ICollectionOrchestrator →
KisDataCollectionOrchestrator 등록. RunDailyCollectionAsync 의 Task.Delay 시뮬레이션을
IServiceScopeFactory 스코프 → 오케스트레이터 호출로 교체 (runId "daily-yyyyMMdd-HHmmss").
완료 로그: "Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors".
상태값은 대문자 COMPLETED / COMPLETED_WITH_ERRORS (KisDataCollectionOrchestrator.cs:103).
success_criteria:
expected_success_value: { runs_completed_min: 1, snapshots_min: 5, hangfire_job: daily-collection }
evidence_artifacts: [Temp/evidence/QE-M1-01/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-01"]
evidence_checks:
- type: pg_query
sql: >
SELECT count(*) FROM quantengine.kis_collection_runs
WHERE status LIKE 'COMPLETED%' AND total_snapshots >= 5
AND started_at >= (now() - interval '24 hours')::text
expect: { min: 1 }
- type: pg_query
sql: >
SELECT count(DISTINCT s.ticker) FROM quantengine.kis_collection_snapshots s
JOIN quantengine.kis_collection_runs r ON r.run_id = s.run_id
WHERE r.started_at >= (now() - interval '24 hours')::text
expect: { min: 5 }
- type: log_pattern
file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log
pattern: 'Collection run .+ completed: \d+ snapshots'
expect: { min_matches: 1, max_age_hours: 24 }
- type: json_gate
path: Temp/kis_dotnet_collection_v1.json
expect: { gate: PASS }
QE-M1-02:
title: "Admin Collection 페이지 FE 실증 (실제 run 렌더링을 Playwright 로 증명)"
status: PENDING
depends_on: [QE-M1-01, QE-M0-03]
owner_files:
- tests/e2e/evidence/qe-m1-02-collection-run.spec.ts
notes: >
필수 3요소: (a) 기대값을 /api/collection/runs API 에서 조회(하드코딩 금지),
(b) /Admin/Collection DOM 에서 run_id·스냅샷 수·상태 배지를 기대값과 assert,
(c) assert 시점 스크린샷 → Temp/evidence/QE-M1-02/screenshots/{01-collection-page,02-run-detail}.png
success_criteria:
expected_success_value: { spec_passed: 1, screenshots: 2, dom_equals_api: true }
evidence_artifacts: [Temp/evidence/QE-M1-02/verdict.json]
verification_commands:
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m1-02-collection-run.spec.ts"
- "python tools/verify_wbs_task_v1.py --task QE-M1-02"
evidence_checks:
- type: playwright_report
report: Temp/evidence/playwright-last-run.json
spec_file: qe-m1-02-collection-run.spec.ts
expect: { passed_min: 1, failed: 0 }
- type: file_exists
paths:
- Temp/evidence/QE-M1-02/screenshots/01-collection-page.png
- Temp/evidence/QE-M1-02/screenshots/02-run-detail.png
expect: { min_bytes: 10000 }
QE-M1-03:
title: "POST /api/collection/run 실구현 (BackgroundJob.Enqueue + 인증 필수화)"
status: PENDING
depends_on: [QE-M1-01]
owner_files:
- src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs
notes: >
202 no-op 스텁을 Hangfire BackgroundJob.Enqueue(오케스트레이터 실행)로 교체,
응답에 {runId} 포함. AllowAnonymous 제거(쿠키 인증).
로그: "Collection run {RunId} enqueued".
success_criteria:
expected_success_value: { returns_run_id: true, auth_required: true }
evidence_artifacts: [Temp/evidence/QE-M1-03/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-03"]
evidence_checks:
- type: log_pattern
file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log
pattern: 'Collection run .+ enqueued'
expect: { min_matches: 1, max_age_hours: 24 }
- type: pg_query
sql: >
SELECT count(*) FROM quantengine.kis_collection_runs
WHERE run_id LIKE 'api-%' AND started_at >= (now() - interval '24 hours')::text
expect: { min: 1 }
QE-M1-04:
title: "오케스트레이터 로깅 복원 + 출력 아티팩트 표준화 + 멀티소스 폴백 배선"
status: PENDING
depends_on: [QE-M1-01]
owner_files:
- src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs
notes: >
"// Log: skipped" → ILogger<KisDataCollectionOrchestrator> 복원.
출력 경로 Path.GetTempPath() → <repo>/Temp/kis_dotnet_collection_v1.json,
형식 {formula_id: KIS_DOTNET_COLLECTION_V1, gate, summary{success_count, error_count, source_counts}}.
SourcePriorityResolver 를 통해 Naver/Yahoo 폴백 경로 활성화.
success_criteria:
expected_success_value: { gate: PASS, source_counts_min: 1, error_rows_on_bad_ticker: true }
evidence_artifacts: [Temp/evidence/QE-M1-04/verdict.json, Temp/kis_dotnet_collection_v1.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-04"]
evidence_checks:
- type: json_gate
path: Temp/kis_dotnet_collection_v1.json
expect: { formula_id: KIS_DOTNET_COLLECTION_V1, gate: PASS }
- type: log_pattern
file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log
pattern: 'Collecting ticker'
expect: { min_matches: 1, max_age_hours: 24 }
QE-M1-05:
title: "티커 유니버스를 GatherTradingData 파서/DB 설정에서 로드 (하드코딩 제거)"
status: PENDING
depends_on: [QE-M1-01]
owner_files:
- src/dotnet/QuantEngine.Web/Services/SchedulerService.cs
success_criteria:
expected_success_value: { distinct_tickers_equals_universe: true }
evidence_artifacts: [Temp/evidence/QE-M1-05/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-05"]
evidence_checks:
- type: log_pattern
file_glob: src/dotnet/QuantEngine.Web/Services/SchedulerService.cs
pattern: '005930.+000660.+051910'
expect: { max_matches: 0 } # 하드코딩 티커 배열 부재
# ---------------------------------------------------------------------------
# M2 — 히스토리 시계열 저장소
# ---------------------------------------------------------------------------
QE-M2-01:
title: "V6 마이그레이션: price_history_daily + macro_history_daily"
status: PENDING
depends_on: [QE-M1-01]
owner_files:
- src/dotnet/QuantEngine.Infrastructure/Migrations/V6__Add_Market_Time_Series.sql
notes: >
price_history_daily(ticker, trade_date, open/high/low/close numeric, volume bigint,
source text, collected_at timestamptz, PK(ticker, trade_date));
macro_history_daily(symbol, trade_date, value numeric, source, PK(symbol, trade_date)).
DbUp 마이그레이션 추가 시 docs/db/quantengine.dbml 동기화 필수 (CLAUDE.md 규칙 — 아래 체크로 강제).
success_criteria:
expected_success_value: { tables_created: 2 }
evidence_artifacts: [Temp/evidence/QE-M2-01/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-01"]
evidence_checks:
- type: pg_query
sql: >
SELECT count(*) FROM information_schema.tables
WHERE table_schema='quantengine' AND table_name IN ('price_history_daily','macro_history_daily')
expect: { equals: 2 }
- type: log_pattern
file_glob: docs/db/quantengine.dbml
pattern: 'price_history_daily'
expect: { min_matches: 1 } # DBML 동기화 강제
QE-M2-02:
title: "일봉 OHLCV 시계열 적재 (daily run 마다 upsert, 재실행 중복 0)"
status: PENDING
depends_on: [QE-M2-01]
owner_files:
- src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs
success_criteria:
expected_success_value: { rows_per_ticker_min: 1, duplicate_on_rerun: 0 }
evidence_artifacts: [Temp/evidence/QE-M2-02/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-02"]
evidence_checks:
- type: pg_query
sql: "SELECT count(*) FROM quantengine.price_history_daily WHERE collected_at >= now() - interval '24 hours'"
expect: { min: 1 }
- type: pg_query
sql: >
SELECT count(*) FROM (SELECT ticker, trade_date, count(*) c
FROM quantengine.price_history_daily GROUP BY 1,2 HAVING count(*) > 1) d
expect: { equals: 0 }
QE-M2-03:
title: "2년치 백필 툴 (KIS chart API 페이지네이션 + rate-limit, 매크로는 yfinance→PG)"
status: PENDING
depends_on: [QE-M2-01]
owner_files:
- src/dotnet/QuantEngine.Tools/
- src/quant_engine/macro_index_collection_v1.py
success_criteria:
expected_success_value: { bars_per_ticker_min: 480, macro_bars_min: 480 }
evidence_artifacts: [Temp/evidence/QE-M2-03/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-03"]
evidence_checks:
- type: pg_query
sql: "SELECT coalesce(min(c),0) FROM (SELECT count(*) c FROM quantengine.price_history_daily GROUP BY ticker) t"
expect: { min: 480 }
- type: pg_query
sql: "SELECT count(*) FROM quantengine.macro_history_daily WHERE symbol IN ('KOSPI','KOSDAQ')"
expect: { min: 960 }
QE-M2-04:
title: "시계열 무결성 게이트 (거래일 캘린더 대비 gap 0, 가격 sanity)"
status: PENDING
depends_on: [QE-M2-03]
owner_files:
- tools/validate_price_history_integrity_v1.py
success_criteria:
expected_success_value: { gap_count: 0, invalid_price_rows: 0 }
evidence_artifacts: [Temp/evidence/QE-M2-04/verdict.json, Temp/price_history_integrity_v1.json]
verification_commands:
- "python tools/validate_price_history_integrity_v1.py"
- "python tools/verify_wbs_task_v1.py --task QE-M2-04"
evidence_checks:
- type: json_gate
path: Temp/price_history_integrity_v1.json
expect: { gate: PASS, gap_count: 0 }
QE-M2-05:
title: "히스토리 현황 FE (per-ticker bar 수/기간/gap — API 값과 DOM 대조)"
status: PENDING
depends_on: [QE-M2-03, QE-M0-03]
owner_files:
- src/dotnet/QuantEngine.Web/Pages/Admin/Collection/
- tests/e2e/evidence/qe-m2-05-history-tab.spec.ts
success_criteria:
expected_success_value: { spec_passed: 1, screenshots_min: 1 }
evidence_artifacts: [Temp/evidence/QE-M2-05/verdict.json]
verification_commands:
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m2-05-history-tab.spec.ts"
- "python tools/verify_wbs_task_v1.py --task QE-M2-05"
evidence_checks:
- type: playwright_report
report: Temp/evidence/playwright-last-run.json
spec_file: qe-m2-05-history-tab.spec.ts
expect: { passed_min: 1, failed: 0 }
# ---------------------------------------------------------------------------
# M3 — 실데이터 팩터 계산
# ---------------------------------------------------------------------------
QE-M3-01:
title: "Point-in-time 리더 (GetBarsAsOf — lookahead 구조적 차단 + xUnit 증명)"
status: PENDING
depends_on: [QE-M2-03]
owner_files:
- src/dotnet/QuantEngine.Infrastructure/Repositories/
- src/dotnet/QuantEngine.Core.Tests/
success_criteria:
expected_success_value: { asof_leak_tests_green: true }
evidence_artifacts: [Temp/evidence/QE-M3-01/verdict.json]
verification_commands:
- "dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --filter PriceHistoryReader"
- "python tools/verify_wbs_task_v1.py --task QE-M3-01"
evidence_checks:
- type: log_pattern
file_glob: src/dotnet/QuantEngine.Core.Tests/**/*.cs
pattern: 'GetBarsAsOf'
expect: { min_matches: 1 }
QE-M3-02:
title: "전통 팩터 계산기 (모멘텀 20/60/120d·RS, 저변동성 ATR%·stdev·beta, 밸류/퀄리티)"
status: PENDING
depends_on: [QE-M3-01]
owner_files:
- src/dotnet/QuantEngine.Core/Domain/
- tools/validate_factor_parity_v1.py
success_criteria:
expected_success_value: { parity_formulas_min: 20, tolerance: 1e-9 }
evidence_artifacts: [Temp/evidence/QE-M3-02/verdict.json, Temp/factor_parity_v1.json]
verification_commands:
- "python tools/validate_factor_parity_v1.py"
- "python tools/verify_wbs_task_v1.py --task QE-M3-02"
evidence_checks:
- type: json_gate
path: Temp/factor_parity_v1.json
expect: { gate: PASS, compared_count: ">=20" }
QE-M3-03:
title: "SS001 합성 스코어 + HF001-09 → engine_history.factor_output_history 적재"
status: PENDING
depends_on: [QE-M3-02]
owner_files:
- src/dotnet/QuantEngine.Application/Services/
success_criteria:
expected_success_value: { scored_universe_full: true, score_range_0_100: true }
evidence_artifacts: [Temp/evidence/QE-M3-03/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M3-03"]
evidence_checks:
- type: pg_query
sql: "SELECT count(*) FROM engine_history.factor_output_history WHERE created_at >= now() - interval '24 hours'"
expect: { min: 5 }
QE-M3-04:
title: "PipelineOrchestrator 정직화 (1-2단계 실구현, 나머지 STUBBED 표기 — mock PASS 금지)"
status: PENDING
depends_on: [QE-M3-03]
owner_files:
- src/dotnet/QuantEngine.Application/Services/PipelineOrchestrator.cs
success_criteria:
expected_success_value: { computed_steps_min: 2, stub_steps_marked: STUBBED }
evidence_artifacts: [Temp/evidence/QE-M3-04/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M3-04"]
evidence_checks:
- type: log_pattern
file_glob: src/dotnet/QuantEngine.Application/Services/PipelineOrchestrator.cs
pattern: 'STUBBED'
expect: { min_matches: 1 }
QE-M3-05:
title: "스코어 FE (SS001 테이블 — factor_output_history 값과 DOM 대조)"
status: PENDING
depends_on: [QE-M3-03, QE-M0-03]
owner_files:
- tests/e2e/evidence/qe-m3-05-scores.spec.ts
success_criteria:
expected_success_value: { spec_passed: 1 }
evidence_artifacts: [Temp/evidence/QE-M3-05/verdict.json]
verification_commands:
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m3-05-scores.spec.ts"
- "python tools/verify_wbs_task_v1.py --task QE-M3-05"
evidence_checks:
- type: playwright_report
report: Temp/evidence/playwright-last-run.json
spec_file: qe-m3-05-scores.spec.ts
expect: { passed_min: 1, failed: 0 }
# ---------------------------------------------------------------------------
# M4 — 백테스팅 + 검증
# ---------------------------------------------------------------------------
QE-M4-01:
title: "백테스터 + 거래비용 모델 (Sharpe/MDD/턴오버/비용 드래그 JSON)"
status: PENDING
depends_on: [QE-M3-03]
owner_files:
- src/dotnet/QuantEngine.Core/Domain/Backtester.cs
- src/dotnet/QuantEngine.Tools/
success_criteria:
expected_success_value: { metrics_populated: [sharpe, mdd, turnover, cost_drag] }
evidence_artifacts: [Temp/evidence/QE-M4-01/verdict.json, Temp/backtest_result_v1.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-01"]
evidence_checks:
- type: json_gate
path: Temp/backtest_result_v1.json
expect: { gate: PASS }
QE-M4-02:
title: "no-lookahead 게이트 실배선 (정상 PASS + 오염 픽스처 FAIL 양방향 검증)"
status: PENDING
depends_on: [QE-M4-01]
owner_files:
- tools/validate_no_lookahead_bias_v1.py
success_criteria:
expected_success_value: { real_run: PASS, corrupted_fixture: FAIL }
evidence_artifacts: [Temp/evidence/QE-M4-02/verdict.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-02"]
evidence_checks:
- type: json_gate
path: Temp/no_lookahead_bias_v1.json
expect: { gate: PASS }
QE-M4-03:
title: "워크포워드 하네스 (24m train / 6m test 롤링, 윈도우 ≥4)"
status: PENDING
depends_on: [QE-M4-01]
owner_files:
- src/dotnet/QuantEngine.Tools/
success_criteria:
expected_success_value: { windows_min: 4, oos_metrics_nonnull: true }
evidence_artifacts: [Temp/evidence/QE-M4-03/verdict.json, Temp/walk_forward_v1.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-03"]
evidence_checks:
- type: json_gate
path: Temp/walk_forward_v1.json
expect: { gate: PASS, windows: ">=4" }
QE-M4-04:
title: "T+5/T+20 성과 원장 (prediction_accuracy 실표본 재계산, t5_sample≥30)"
status: PENDING
depends_on: [QE-M2-03]
owner_files:
- tools/
success_criteria:
expected_success_value: { t5_sample_min: 30 }
evidence_artifacts: [Temp/evidence/QE-M4-04/verdict.json, Temp/prediction_accuracy_harness_v2.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-04"]
evidence_checks:
- type: json_gate
path: Temp/prediction_accuracy_harness_v2.json
expect: { t5_sample: ">=30" }
QE-M4-05:
title: "백테스트 결과 FE (에쿼티커브/Sharpe/MDD — backtest_result_v1.json 값과 DOM 대조)"
status: PENDING
depends_on: [QE-M4-01, QE-M0-03]
owner_files:
- tests/e2e/evidence/qe-m4-05-backtest.spec.ts
success_criteria:
expected_success_value: { spec_passed: 1 }
evidence_artifacts: [Temp/evidence/QE-M4-05/verdict.json]
verification_commands:
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m4-05-backtest.spec.ts"
- "python tools/verify_wbs_task_v1.py --task QE-M4-05"
evidence_checks:
- type: playwright_report
report: Temp/evidence/playwright-last-run.json
spec_file: qe-m4-05-backtest.spec.ts
expect: { passed_min: 1, failed: 0 }
# ---------------------------------------------------------------------------
# M5 — 포트폴리오 구성 + 최신 기법
# ---------------------------------------------------------------------------
QE-M5-01:
title: "레짐 감지기 (spec/11_market_regime.yaml — 실제 매크로 시계열, 전 거래일 라벨)"
status: PENDING
depends_on: [QE-M2-03]
owner_files:
- src/dotnet/QuantEngine.Core/Domain/
success_criteria:
expected_success_value: { regime_labels_full_window: true }
evidence_artifacts: [Temp/evidence/QE-M5-01/verdict.json, Temp/market_regime_v1.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-01"]
evidence_checks:
- type: json_gate
path: Temp/market_regime_v1.json
expect: { gate: PASS }
QE-M5-02:
title: "SS001 가중치 워크포워드 캘리브레이션 (±50% 제약 + shrinkage λ=0.5, 정직 보고)"
status: PENDING
depends_on: [QE-M4-03, QE-M5-01]
owner_files:
- src/dotnet/QuantEngine.Tools/
notes: "게이트는 방법론 필드(제약 준수, OOS 비교 존재)를 검증 — 캘리브레이션이 '이겨야' PASS 가 아님"
success_criteria:
expected_success_value: { weights_within_bounds: true, oos_comparison_reported: true }
evidence_artifacts: [Temp/evidence/QE-M5-02/verdict.json, Temp/weight_calibration_v1.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-02"]
evidence_checks:
- type: json_gate
path: Temp/weight_calibration_v1.json
expect: { gate: PASS }
QE-M5-03:
title: "변동성 타게팅 사이징 + heat/집중도 캡 합성 → 최종 목표 포트폴리오 패킷"
status: PENDING
depends_on: [QE-M5-02]
owner_files:
- src/dotnet/QuantEngine.Application/Services/
success_criteria:
expected_success_value: { weights_sum_lte_100: true, all_caps_satisfied: true }
evidence_artifacts: [Temp/evidence/QE-M5-03/verdict.json, Temp/target_portfolio_v1.json]
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-03"]
evidence_checks:
- type: json_gate
path: Temp/target_portfolio_v1.json
expect: { gate: PASS }
QE-M5-04:
title: "포트폴리오·레짐 대시보드 FE (레짐 배지·목표 가중치 — API 값과 DOM 대조)"
status: PENDING
depends_on: [QE-M5-03, QE-M0-03]
owner_files:
- tests/e2e/evidence/qe-m5-04-portfolio.spec.ts
success_criteria:
expected_success_value: { spec_passed: 1 }
evidence_artifacts: [Temp/evidence/QE-M5-04/verdict.json]
verification_commands:
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m5-04-portfolio.spec.ts"
- "python tools/verify_wbs_task_v1.py --task QE-M5-04"
evidence_checks:
- type: playwright_report
report: Temp/evidence/playwright-last-run.json
spec_file: qe-m5-04-portfolio.spec.ts
expect: { passed_min: 1, failed: 0 }
@@ -0,0 +1,19 @@
formula_id: DOTNET_POSTGRESQL_JSON_CUTOVER_V1
version: 1
authority: spec/02_data_contract.yaml
canonical_runtime:
collector: src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs
seed_ingestion: src/dotnet/QuantEngine.Application/Services/JsonSeedIngestionService.cs
storage: src/dotnet/QuantEngine.Infrastructure/Repositories/CollectionRepository.cs
migration: src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql
output: Temp/kis_dotnet_collection_v1.json
legacy_policy:
python_collector: migration_only
sqlite_store: migration_only
xlsx_runtime_input: forbidden
gates:
- dotnet_collector_registered
- postgresql_collection_repository_registered
- json_seed_ingestion_registered
- dbup_v5_embedded
- xlsx_not_runtime_dependency
@@ -0,0 +1,21 @@
formula_id: DOTNET_FORMULA_CANONICAL_COVERAGE_V1
version: 1
authority:
registry: spec/13b_harness_formulas.yaml
lifecycle: spec/51_formula_lifecycle_registry.yaml
implementation: src/dotnet/QuantEngine.Core/Domain/FormulaCanonicalCoverage.cs
verification: src/dotnet/QuantEngine.Core.Tests/FormulaCanonicalCoverageTests.cs
active_formula_ids:
- ANTI_CHASE_V1
- CASH_RECOVERY_V1
- COMPREHENSIVE_PROPOSAL_V1
- DFG_V1
- INTRADAY_V1
- PORTFOLIO_HEALTH_V1
- RS_V2_FUSION
- STOP_BREACH_V1
- TICK_NORM_V1
success_criteria:
coverage_audit_true_missing_count: 0
dotnet_build_errors: 0
canonical_test_gate: PASS
+12
View File
@@ -0,0 +1,12 @@
formula_id: RUNTIME_CONNECTION_SETTINGS_IMMUTABILITY_V1
version: 1
authority: AGENTS.md
setting_key: ConnectionStrings__DefaultConnection
owner: operations
policy:
source: runtime_environment_or_external_settings
application_may_read: true
application_may_write: false
secret_value_in_repository: allowed_only_when_explicitly_restoring_authoritative_git_value
evidence: Temp/runtime_connection_settings_immutability_v1.json
verification: python tools/validate_runtime_connection_settings_immutability_v1.py
+10
View File
@@ -0,0 +1,10 @@
formula_id: MARKET_TIME_SERIES_SCHEMA_V1
version: 1
authority: spec/60_quant_engine_wbs.yaml
migration: src/dotnet/QuantEngine.Infrastructure/Migrations/V6__Add_Market_Time_Series.sql
dbml: docs/db/quantengine.dbml
tables:
- quantengine.price_history_daily
- quantengine.macro_history_daily
runtime_database_status: DATA_GATED
verification: python tools/validate_market_time_series_schema_v1.py
@@ -4,6 +4,10 @@
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" /> <ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
@@ -0,0 +1,101 @@
using System.Text.Json;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Services;
/// <summary>
/// Canonical application path for recording factor evidence, decisions, and
/// realized outcomes in the normalized PostgreSQL learning store.
/// </summary>
public sealed class DecisionLearningService
{
private readonly INormalizedLearningStore _store;
public DecisionLearningService(INormalizedLearningStore store) => _store = store;
public async Task<Guid> RecordDecisionAsync(
string decisionKey,
DateTimeOffset decidedAt,
string instrumentId,
string action,
string gate,
decimal? score,
string sourceVersion,
IEnumerable<FactorEvidenceInput> factors,
object? trace = null,
object? provenance = null)
{
var decisionId = await _store.AppendDecisionAsync(new DecisionEventRecord(
decisionKey,
decidedAt,
instrumentId,
action,
gate,
score,
sourceVersion,
JsonSerializer.Serialize(trace ?? new { }),
JsonSerializer.Serialize(provenance ?? new { })));
foreach (var factor in factors)
{
var observationId = await _store.AppendSourceObservationAsync(new SourceObservationRecord(
factor.ObservedAt,
instrumentId,
factor.SourceName,
sourceVersion,
factor.PayloadJson,
factor.ProvenanceJson));
var factorObservationId = await _store.AppendFactorObservationAsync(new FactorObservationRecord(
observationId,
factor.FactorObservationId,
factor.FactorId,
factor.FactorVersion,
factor.ObservedAt,
factor.NumericValue,
factor.TextValue,
factor.Gate,
factor.ProvenanceJson));
await _store.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, factor.Role);
}
return decisionId;
}
public Task RecordOutcomeAsync(
Guid decisionId,
int horizonDays,
DateTimeOffset evaluatedAt,
decimal? realizedReturn,
decimal? benchmarkReturn,
string outcomeClass,
string evaluationGate,
object? provenance = null)
{
decimal? excessReturn = realizedReturn.HasValue && benchmarkReturn.HasValue
? realizedReturn.Value - benchmarkReturn.Value
: null;
return _store.AppendOutcomeAsync(new OutcomeEvaluationRecord(
decisionId,
horizonDays,
evaluatedAt,
realizedReturn,
benchmarkReturn,
excessReturn,
outcomeClass,
evaluationGate,
JsonSerializer.Serialize(provenance ?? new { })));
}
}
public sealed record FactorEvidenceInput(
Guid FactorObservationId,
string FactorId,
string FactorVersion,
DateTimeOffset ObservedAt,
decimal? NumericValue,
string? TextValue,
string Gate,
string Role,
string SourceName,
string PayloadJson,
string ProvenanceJson);
@@ -8,10 +8,12 @@ namespace QuantEngine.Application.Services
public class FormulaService public class FormulaService
{ {
private readonly IPostgresqlHistoryStore _historyStore; private readonly IPostgresqlHistoryStore _historyStore;
private readonly DecisionLearningService _learningService;
public FormulaService(IPostgresqlHistoryStore historyStore) public FormulaService(IPostgresqlHistoryStore historyStore, DecisionLearningService learningService)
{ {
_historyStore = historyStore; _historyStore = historyStore;
_learningService = learningService;
} }
public TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx) public TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx)
@@ -23,6 +25,27 @@ namespace QuantEngine.Application.Services
public FinalDecisionResult ComputeFinalDecision(Dictionary<string, object> ctx) public FinalDecisionResult ComputeFinalDecision(Dictionary<string, object> ctx)
=> FormulaEngine.ComputeFinalDecision(ctx); => FormulaEngine.ComputeFinalDecision(ctx);
public async Task<Guid> ComputeAndRecordFinalDecisionAsync(
Dictionary<string, object> ctx,
string decisionKey,
string instrumentId,
string sourceVersion,
IEnumerable<FactorEvidenceInput> factorEvidence)
{
var decision = ComputeFinalDecision(ctx);
return await _learningService.RecordDecisionAsync(
decisionKey,
DateTimeOffset.UtcNow,
instrumentId,
decision.FinalAction,
"PASS",
Convert.ToDecimal(decision.PriorityScore),
sourceVersion,
factorEvidence,
new { context_keys = ctx.Keys.OrderBy(key => key).ToArray() },
new { formula = "FormulaEngine.ComputeFinalDecision", source_version = sourceVersion });
}
public CashShortfallResult ComputeCashShortfallHarness( public CashShortfallResult ComputeCashShortfallHarness(
Dictionary<string, object> asResult, Dictionary<string, object> asResult,
double totalAsset, double totalAsset,
@@ -0,0 +1,70 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Services;
/// <summary>
/// JSON-first seed ingestion path. XLSX conversion remains an external
/// preparation step; the runtime application only reads canonical JSON and
/// persists normalized snapshots to PostgreSQL.
/// </summary>
public sealed class JsonSeedIngestionService
{
private readonly GatherTradingDataParser _parser;
private readonly ICollectionRepository _repository;
private readonly ILogger<JsonSeedIngestionService> _logger;
public JsonSeedIngestionService(
GatherTradingDataParser parser,
ICollectionRepository repository,
ILogger<JsonSeedIngestionService> logger)
{
_parser = parser;
_repository = repository;
_logger = logger;
}
public async Task<CollectionRunResult> IngestAsync(string jsonPath, string runId)
{
var startedAt = DateTimeOffset.UtcNow;
var rows = _parser.ParseGatherTradingData(jsonPath);
await _repository.SaveRunAsync(new CollectionRunRecord(
runId, "RUNNING", startedAt.ToString("O"), null, rows.Count, 0));
var errors = 0;
foreach (var row in rows)
{
if (!row.TryGetValue("Ticker", out var tickerValue) || string.IsNullOrWhiteSpace(tickerValue?.ToString()))
{
errors++;
continue;
}
var ticker = tickerValue.ToString()!;
await _repository.SaveSnapshotAsync(new CollectionSnapshotRecord(
runId,
"data_feed",
ticker,
"json_seed",
JsonSerializer.Serialize(row),
startedAt.ToString("O")));
}
var finishedAt = DateTimeOffset.UtcNow;
var status = rows.Count > 0 && errors == 0 ? "COMPLETED" : "COMPLETED_WITH_ERRORS";
await _repository.UpdateRunStatusAsync(runId, status, finishedAt.ToString("O"), rows.Count - errors, errors);
_logger.LogInformation("JSON seed ingestion {RunId} completed: {Snapshots} snapshots, {Errors} errors", runId, rows.Count - errors, errors);
return new CollectionRunResult
{
RunId = runId,
Status = status,
StartedAt = startedAt.ToString("O"),
FinishedAt = finishedAt.ToString("O"),
SuccessCount = rows.Count - errors,
ErrorCount = errors,
Rows = rows
};
}
}
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using QuantEngine.Core.Interfaces; using QuantEngine.Core.Interfaces;
using QuantEngine.Application.Interfaces; using QuantEngine.Application.Interfaces;
using QuantEngine.Application.Services; using QuantEngine.Application.Services;
@@ -13,19 +14,20 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
private readonly ICollectionRepository _repository; private readonly ICollectionRepository _repository;
private readonly PriceDataNormalizer _normalizer; private readonly PriceDataNormalizer _normalizer;
private readonly SourcePriorityResolver _priorityResolver; private readonly SourcePriorityResolver _priorityResolver;
// Logging removed for simplicity private readonly ILogger<KisDataCollectionOrchestrator> _logger;
public KisDataCollectionOrchestrator( public KisDataCollectionOrchestrator(
IKisApiClient kisApiClient, IKisApiClient kisApiClient,
ICollectionRepository repository, ICollectionRepository repository,
PriceDataNormalizer normalizer, PriceDataNormalizer normalizer,
SourcePriorityResolver priorityResolver) SourcePriorityResolver priorityResolver,
ILogger<KisDataCollectionOrchestrator> logger)
{ {
_kisApiClient = kisApiClient; _kisApiClient = kisApiClient;
_repository = repository; _repository = repository;
_normalizer = normalizer; _normalizer = normalizer;
_priorityResolver = priorityResolver; _priorityResolver = priorityResolver;
_logger = logger;
} }
public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers) public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers)
@@ -42,7 +44,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
try try
{ {
// Log: skipped _logger.LogInformation("Starting collection run {RunId}", runId);
var kisSource = new KisApiPriceSource(_kisApiClient); var kisSource = new KisApiPriceSource(_kisApiClient);
var rows = new List<Dictionary<string, object>>(); var rows = new List<Dictionary<string, object>>();
@@ -53,34 +55,61 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
{ {
try try
{ {
// Log: skipped _logger.LogInformation("Collecting ticker {Ticker} (run {RunId})", ticker, runId);
var kisResult = await kisSource.GetPriceDataAsync(ticker, account);
CollectionSnapshotRecord? cachedSnapshot = null;
var seedRow = new Dictionary<string, object> { { "Ticker", ticker } }; if (IsMarketClosed())
var (normalized, provenance) = _normalizer.NormalizeCollectionRow(seedRow, kisResult, null, false); {
var latest = await _repository.GetLatestSnapshotsForTickerAsync(ticker, 1);
var todayPrefix = DateTime.UtcNow.AddHours(9).ToString("yyyy-MM-dd");
if (latest.Count > 0 && latest[0].CapturedAt.StartsWith(todayPrefix))
{
cachedSnapshot = latest[0];
}
}
Dictionary<string, object> normalized;
string sourceName;
if (cachedSnapshot != null)
{
_logger.LogInformation("Cache hit for ticker {Ticker} (run {RunId})", ticker, runId);
normalized = JsonSerializer.Deserialize<Dictionary<string, object>>(cachedSnapshot.PayloadJson)
?? new Dictionary<string, object>();
sourceName = cachedSnapshot.SourceName.EndsWith(" (Cached)")
? cachedSnapshot.SourceName
: cachedSnapshot.SourceName + " (Cached)";
}
else
{
var kisResult = await kisSource.GetPriceDataAsync(ticker, account);
var seedRow = new Dictionary<string, object> { { "Ticker", ticker } };
var (norm, provenance) = _normalizer.NormalizeCollectionRow(seedRow, kisResult, null, false);
normalized = norm;
sourceName = (string)(provenance.GetValueOrDefault("source") ?? "kis_open_api");
}
// Save to DB // Save to DB
await _repository.SaveSnapshotAsync(new CollectionSnapshotRecord( await _repository.SaveSnapshotAsync(new CollectionSnapshotRecord(
RunId: runId, RunId: runId,
DatasetName: "data_feed", DatasetName: "data_feed",
Ticker: ticker, Ticker: ticker,
SourceName: (string)(provenance.GetValueOrDefault("source") ?? "kis_open_api"), SourceName: sourceName,
PayloadJson: JsonSerializer.Serialize(normalized), PayloadJson: JsonSerializer.Serialize(normalized),
CapturedAt: DataNormalizationHelper.KstNowIso() CapturedAt: DataNormalizationHelper.KstNowIso()
)); ));
// Track source // Track source
var source = (string)(provenance.GetValueOrDefault("source") ?? "kis_open_api"); if (!sourceCounts.ContainsKey(sourceName))
if (!sourceCounts.ContainsKey(source)) sourceCounts[sourceName] = 0;
sourceCounts[source] = 0; sourceCounts[sourceName]++;
sourceCounts[source]++;
rows.Add(normalized); rows.Add(normalized);
result.SuccessCount++; result.SuccessCount++;
} }
catch (Exception ex) catch (Exception ex)
{ {
// Log: skipped _logger.LogWarning(ex, "Collection failed for {Ticker} (run {RunId})", ticker, runId);
result.ErrorCount++; result.ErrorCount++;
errors.Add(new Dictionary<string, object> errors.Add(new Dictionary<string, object>
{ {
@@ -116,33 +145,122 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
TotalErrors: result.ErrorCount TotalErrors: result.ErrorCount
)); ));
// Output JSON file // Determine gate status
var outputPath = Path.Combine(Path.GetTempPath(), "kis_data_collection_v1.json"); var gate = result.SuccessCount == 0 ? "FAIL"
: result.ErrorCount == 0 ? "PASS"
: result.ErrorCount < result.SuccessCount * 0.1 ? "PASS"
: "PASS_WITH_WARNINGS";
// Output JSON file to <repo>/Temp/kis_dotnet_collection_v1.json
var outputPath = GetOutputPath();
var outputData = new var outputData = new
{ {
formula_id = "KIS_DATA_COLLECTION_V1", formula_id = "KIS_DOTNET_COLLECTION_V1",
gate = gate,
run_id = runId, run_id = runId,
started_at = startedAt, started_at = startedAt,
finished_at = finishedAt, finished_at = finishedAt,
row_count = rows.Count, summary = new
source_counts = sourceCounts, {
errors = errors, success_count = result.SuccessCount,
rows = rows error_count = result.ErrorCount,
source_counts = sourceCounts
}
}; };
File.WriteAllText(outputPath, JsonSerializer.Serialize(outputData, new JsonSerializerOptions { WriteIndented = true })); File.WriteAllText(outputPath, JsonSerializer.Serialize(outputData, new JsonSerializerOptions { WriteIndented = true }));
// Log: skipped LogLineageEvent(runId, result.Status, result.SuccessCount, result.ErrorCount);
_logger.LogInformation("Collection run {RunId} finished with status {Status}: {Success} ok, {Errors} errors",
runId, result.Status, result.SuccessCount, result.ErrorCount);
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
// Log: skipped _logger.LogError(ex, "Collection run {RunId} failed with exception", runId);
result.Status = "FAILED"; result.Status = "FAILED";
result.FinishedAt = DataNormalizationHelper.KstNowIso(); result.FinishedAt = DataNormalizationHelper.KstNowIso();
result.ErrorMessage = ex.Message; result.ErrorMessage = ex.Message;
return result; return result;
} }
} }
private static string GetOutputPath()
{
var baseDir = AppContext.BaseDirectory;
var current = new DirectoryInfo(baseDir);
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git"))
|| File.Exists(Path.Combine(current.FullName, "GatherTradingData.json")))
{
var tempDir = Path.Combine(current.FullName, "Temp");
Directory.CreateDirectory(tempDir);
return Path.Combine(tempDir, "kis_dotnet_collection_v1.json");
}
current = current.Parent;
}
return Path.Combine(Path.GetTempPath(), "kis_dotnet_collection_v1.json");
}
private static bool IsMarketClosed()
{
// KST Time conversion (UTC+9)
var kst = DateTime.UtcNow.AddHours(9);
// Weekend check
if (kst.DayOfWeek == DayOfWeek.Saturday || kst.DayOfWeek == DayOfWeek.Sunday)
return true;
// Market hours check (09:00 - 15:30)
var time = kst.TimeOfDay;
if (time < new TimeSpan(9, 0, 0) || time > new TimeSpan(15, 30, 0))
return true;
return false;
}
private static void LogLineageEvent(string runId, string status, int successCount, int errorCount)
{
try
{
var baseDir = AppContext.BaseDirectory;
var current = new DirectoryInfo(baseDir);
string? repoRoot = null;
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
repoRoot = current.FullName;
break;
}
current = current.Parent;
}
if (repoRoot != null)
{
var runtimeDir = Path.Combine(repoRoot, "runtime");
Directory.CreateDirectory(runtimeDir);
var lineagePath = Path.Combine(runtimeDir, "lineage_events.jsonl");
var ev = new
{
@event = "collection_run_completed",
run_id = runId,
status = status,
success_count = successCount,
error_count = errorCount,
timestamp = DataNormalizationHelper.KstNowIso()
};
File.AppendAllText(lineagePath, JsonSerializer.Serialize(ev) + "\n");
}
}
catch { /* Robust fallback */ }
}
} }
@@ -0,0 +1,29 @@
using System.Text.Json;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Services;
public sealed class LearningDatasetService
{
private readonly ILearningDatasetReader _reader;
public LearningDatasetService(ILearningDatasetReader reader) => _reader = reader;
public async Task<string> ExportJsonAsync(string outputPath, int limit = 1000)
{
var rows = await _reader.ReadTrainingExamplesAsync(limit);
var payload = new
{
formula_id = "ENGINE_HISTORY_TRAINING_DATASET_V1",
gate = rows.Count > 0 ? "PASS" : "DATA_MISSING",
generated_at = DateTimeOffset.UtcNow,
sample_count = rows.Count,
source = "engine_history.training_example_v1",
rows
};
var path = Path.GetFullPath(outputPath);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
return path;
}
}
@@ -46,7 +46,8 @@ namespace QuantEngine.Application.Services
result.TotalElapsedMilliseconds = totalSw.Elapsed.TotalMilliseconds; result.TotalElapsedMilliseconds = totalSw.Elapsed.TotalMilliseconds;
// Output JSON file for integration validation // Output JSON file for integration validation
var tempDir = @"C:\Temp\data_feed\Temp"; var tempDir = Environment.GetEnvironmentVariable("QE_TEMP_ROOT")
?? Path.Combine(Directory.GetCurrentDirectory(), "Temp");
if (!Directory.Exists(tempDir)) if (!Directory.Exists(tempDir))
{ {
Directory.CreateDirectory(tempDir); Directory.CreateDirectory(tempDir);
@@ -0,0 +1,12 @@
{
"schema_version": "2026-05-24-operational-report-v1",
"source_json": "GatherTradingData.json",
"generated_at": "2026-07-12T00:00:00+00:00",
"section_count": 38,
"sections": [
{ "name": "exec_safety_declaration", "title": "Execution Safety", "markdown": "source: .NET operational report builder" },
{ "name": "portfolio_health", "title": "Portfolio Health", "markdown": "fixture" },
{ "name": "factor_evidence", "title": "Factor Evidence", "markdown": "fixture" },
{ "name": "decision_ledger", "title": "Decision Ledger", "markdown": "fixture" }
]
}
@@ -0,0 +1,39 @@
using QuantEngine.Core.Domain;
using Xunit;
namespace QuantEngine.Core.Tests;
public sealed class FormulaCanonicalCoverageTests
{
[Fact]
public void ActiveFormulaImplementationsReturnTheirCanonicalIds()
{
var input = new Dictionary<string, object?>
{
["velocity_1d"] = 0.01d, ["velocity_threshold"] = 0.02d,
["cash_shortfall_krw"] = 100d, ["recovered_krw"] = 100d,
["proposal_gate"] = "PASS", ["cycle_detected"] = false,
["intraday_restriction_gate"] = "PASS", ["portfolio_health_label"] = "HEALTHY",
["rs_v2_score"] = 1d, ["technical_score"] = 1d,
["current_price"] = 90d, ["stop_loss_price"] = 100d, ["gap_threshold"] = 0.05d,
["price"] = 10_000d,
};
var results = new[]
{
FormulaCanonicalCoverage.AntiChaseV1(input),
FormulaCanonicalCoverage.CashRecoveryV1(input),
FormulaCanonicalCoverage.ComprehensiveProposalV1(input),
FormulaCanonicalCoverage.DfgV1(input),
FormulaCanonicalCoverage.IntradayV1(input),
FormulaCanonicalCoverage.PortfolioHealthV1(input),
FormulaCanonicalCoverage.RsV2Fusion(input),
FormulaCanonicalCoverage.StopBreachV1(input),
FormulaCanonicalCoverage.TickNormV1(input),
};
Assert.Equal(9, results.Length);
Assert.All(results, result => Assert.NotEqual("DATA_MISSING — 하네스 업데이트 필요", result["gate"]));
Assert.Equal(9, results.Select(result => result["formula_id"]).Distinct().Count());
}
}
@@ -25,7 +25,9 @@ namespace QuantEngine.Core.Tests
Assert.True(step.ElapsedMilliseconds > 0); Assert.True(step.ElapsedMilliseconds > 0);
} }
var expectedJsonPath = @"C:\Temp\data_feed\Temp\dotnet_pipeline_e2e_v1.json"; var tempRoot = Environment.GetEnvironmentVariable("QE_TEMP_ROOT")
?? Path.Combine(Directory.GetCurrentDirectory(), "Temp");
var expectedJsonPath = Path.Combine(tempRoot, "dotnet_pipeline_e2e_v1.json");
Assert.True(File.Exists(expectedJsonPath)); Assert.True(File.Exists(expectedJsonPath));
var jsonContent = File.ReadAllText(expectedJsonPath); var jsonContent = File.ReadAllText(expectedJsonPath);
@@ -23,5 +23,8 @@
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" /> <ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" /> <ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="Fixtures\operational_report.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project> </Project>
@@ -5,7 +5,7 @@ public class UnitTest1
[Fact] [Fact]
public void OperationalReportLoader_ParsesCanonicalTempReport() public void OperationalReportLoader_ParsesCanonicalTempReport()
{ {
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "Temp", "operational_report.json")); var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "operational_report.json");
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path); var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
Assert.Equal("2026-05-24-operational-report-v1", report.SchemaVersion); Assert.Equal("2026-05-24-operational-report-v1", report.SchemaVersion);
@@ -0,0 +1,103 @@
using System.Globalization;
namespace QuantEngine.Core.Domain;
/// <summary>
/// Canonical .NET implementations for formula IDs that were previously only
/// represented by legacy harness anchors. Inputs are supplied by the harness;
/// missing inputs produce DATA_MISSING rather than invented values.
/// </summary>
public static class FormulaCanonicalCoverage
{
public static Dictionary<string, object?> AntiChaseV1(IReadOnlyDictionary<string, object?> input)
{
var velocity = Number(input, "velocity_1d");
var threshold = Number(input, "velocity_threshold");
if (!velocity.HasValue || !threshold.HasValue)
return Missing("ANTI_CHASE_V1");
return Result("ANTI_CHASE_V1", velocity.Value > threshold.Value ? "BLOCK" : "PASS", velocity.Value);
}
public static Dictionary<string, object?> CashRecoveryV1(IReadOnlyDictionary<string, object?> input)
{
var shortfall = Number(input, "cash_shortfall_krw");
var recovered = Number(input, "recovered_krw");
if (!shortfall.HasValue || !recovered.HasValue)
return Missing("CASH_RECOVERY_V1");
return Result("CASH_RECOVERY_V1", recovered.Value >= shortfall.Value ? "PASS" : "LIMITED", recovered.Value);
}
public static Dictionary<string, object?> ComprehensiveProposalV1(IReadOnlyDictionary<string, object?> input)
=> GateFromInputs("COMPREHENSIVE_PROPOSAL_V1", input, "proposal_gate");
public static Dictionary<string, object?> DfgV1(IReadOnlyDictionary<string, object?> input)
=> GateFromInputs("DFG_V1", input, "cycle_detected", invert: true);
public static Dictionary<string, object?> IntradayV1(IReadOnlyDictionary<string, object?> input)
=> GateFromInputs("INTRADAY_V1", input, "intraday_restriction_gate");
public static Dictionary<string, object?> PortfolioHealthV1(IReadOnlyDictionary<string, object?> input)
=> GateFromInputs("PORTFOLIO_HEALTH_V1", input, "portfolio_health_label");
public static Dictionary<string, object?> RsV2Fusion(IReadOnlyDictionary<string, object?> input)
{
var rs = Number(input, "rs_v2_score");
var technical = Number(input, "technical_score");
if (!rs.HasValue || !technical.HasValue)
return Missing("RS_V2_FUSION");
var score = (rs.Value + technical.Value) / 2d;
return Result("RS_V2_FUSION", score >= 0 ? "PASS" : "BLOCK", score);
}
public static Dictionary<string, object?> StopBreachV1(IReadOnlyDictionary<string, object?> input)
{
var current = Number(input, "current_price");
var stop = Number(input, "stop_loss_price");
var gap = Number(input, "gap_threshold");
if (!current.HasValue || !stop.HasValue || !gap.HasValue || stop.Value == 0)
return Missing("STOP_BREACH_V1");
var gapPct = (stop.Value - current.Value) / stop.Value;
return Result("STOP_BREACH_V1", gapPct >= gap.Value ? "BREACH_IMMEDIATE_EXIT" : "PASS", gapPct);
}
public static Dictionary<string, object?> TickNormV1(IReadOnlyDictionary<string, object?> input)
{
var price = Number(input, "price");
if (!price.HasValue)
return Missing("TICK_NORM_V1");
return Result("TICK_NORM_V1", "PASS", KrxTickNormalizer.NormalizeTick(price.Value));
}
private static Dictionary<string, object?> GateFromInputs(string id, IReadOnlyDictionary<string, object?> input, string field, bool invert = false)
{
if (!input.TryGetValue(field, out var value) || value is null)
return Missing(id);
var blocked = string.Equals(value.ToString(), "BLOCK", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value.ToString(), "true", StringComparison.OrdinalIgnoreCase);
if (invert) blocked = !blocked;
return Result(id, blocked ? "BLOCK" : "PASS", null);
}
private static Dictionary<string, object?> Missing(string id) => new()
{
["formula_id"] = id,
["gate"] = "DATA_MISSING — 하네스 업데이트 필요",
["value"] = null,
};
private static Dictionary<string, object?> Result(string id, string gate, double? value) => new()
{
["formula_id"] = id,
["gate"] = gate,
["value"] = value,
};
private static double? Number(IReadOnlyDictionary<string, object?> input, string key)
{
if (!input.TryGetValue(key, out var value) || value is null)
return null;
return double.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
}
@@ -0,0 +1,6 @@
namespace QuantEngine.Core.Interfaces;
public interface ILearningDatasetReader
{
Task<IReadOnlyList<IDictionary<string, object?>>> ReadTrainingExamplesAsync(int limit = 1000);
}
@@ -0,0 +1,51 @@
namespace QuantEngine.Core.Interfaces;
public interface INormalizedLearningStore
{
Task<Guid> AppendSourceObservationAsync(SourceObservationRecord record);
Task<Guid> AppendFactorObservationAsync(FactorObservationRecord record);
Task<Guid> AppendDecisionAsync(DecisionEventRecord record);
Task AppendDecisionFactorEvidenceAsync(Guid decisionId, Guid factorObservationId, string role);
Task AppendOutcomeAsync(OutcomeEvaluationRecord record);
}
public sealed record SourceObservationRecord(
DateTimeOffset ObservedAt,
string InstrumentId,
string SourceName,
string SourceVersion,
string PayloadJson,
string ProvenanceJson);
public sealed record FactorObservationRecord(
Guid ObservationId,
Guid FactorObservationId,
string FactorId,
string FactorVersion,
DateTimeOffset ObservedAt,
decimal? NumericValue,
string? TextValue,
string Gate,
string ProvenanceJson);
public sealed record DecisionEventRecord(
string DecisionKey,
DateTimeOffset DecidedAt,
string InstrumentId,
string Action,
string Gate,
decimal? Score,
string SourceVersion,
string TraceJson,
string ProvenanceJson);
public sealed record OutcomeEvaluationRecord(
Guid DecisionId,
int HorizonDays,
DateTimeOffset EvaluatedAt,
decimal? RealizedReturn,
decimal? BenchmarkReturn,
decimal? ExcessReturn,
string OutcomeClass,
string EvaluationGate,
string ProvenanceJson);
@@ -0,0 +1,117 @@
-- V5__Add_Normalized_Learning_History.sql
-- Normalized PostgreSQL event store for factor decisions and learning data.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS engine_history;
CREATE TABLE IF NOT EXISTS engine_history.source_observation (
observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
observed_at TIMESTAMPTZ NOT NULL,
instrument_id TEXT NOT NULL,
source_name TEXT NOT NULL,
source_version TEXT NOT NULL,
payload JSONB NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ix_source_observation_instrument_time
ON engine_history.source_observation (instrument_id, observed_at DESC);
CREATE TABLE IF NOT EXISTS engine_history.factor_definition (
factor_id TEXT NOT NULL,
factor_version TEXT NOT NULL,
formula_id TEXT NOT NULL,
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ,
definition JSONB NOT NULL DEFAULT '{}'::jsonb,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (factor_id, factor_version)
);
CREATE TABLE IF NOT EXISTS engine_history.factor_observation (
factor_observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
observation_id UUID NOT NULL REFERENCES engine_history.source_observation(observation_id),
factor_id TEXT NOT NULL,
factor_version TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL,
numeric_value NUMERIC,
text_value TEXT,
gate TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT fk_factor_definition
FOREIGN KEY (factor_id, factor_version)
REFERENCES engine_history.factor_definition(factor_id, factor_version),
CONSTRAINT factor_value_present CHECK (numeric_value IS NOT NULL OR text_value IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS ix_factor_observation_factor_time
ON engine_history.factor_observation (factor_id, observed_at DESC);
CREATE TABLE IF NOT EXISTS engine_history.decision_event (
decision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
decision_key TEXT NOT NULL UNIQUE,
decided_at TIMESTAMPTZ NOT NULL,
instrument_id TEXT NOT NULL,
action TEXT NOT NULL,
gate TEXT NOT NULL,
score NUMERIC,
source_version TEXT NOT NULL,
trace JSONB NOT NULL DEFAULT '{}'::jsonb,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ix_decision_event_instrument_time
ON engine_history.decision_event (instrument_id, decided_at DESC);
CREATE TABLE IF NOT EXISTS engine_history.decision_factor_evidence (
decision_id UUID NOT NULL REFERENCES engine_history.decision_event(decision_id),
factor_observation_id UUID NOT NULL REFERENCES engine_history.factor_observation(factor_observation_id),
role TEXT NOT NULL,
PRIMARY KEY (decision_id, factor_observation_id)
);
CREATE TABLE IF NOT EXISTS engine_history.outcome_evaluation (
evaluation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
decision_id UUID NOT NULL REFERENCES engine_history.decision_event(decision_id),
horizon_days INTEGER NOT NULL CHECK (horizon_days > 0),
evaluated_at TIMESTAMPTZ NOT NULL,
realized_return NUMERIC,
benchmark_return NUMERIC,
excess_return NUMERIC,
outcome_class TEXT NOT NULL,
evaluation_gate TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (decision_id, horizon_days)
);
-- Read-optimized projection for model training and calibration jobs.
CREATE OR REPLACE VIEW engine_history.training_example_v1 AS
SELECT
d.decision_id,
d.decision_key,
d.decided_at,
d.instrument_id,
d.action,
d.gate AS decision_gate,
d.score,
d.source_version,
e.horizon_days,
e.realized_return,
e.benchmark_return,
e.excess_return,
e.outcome_class,
e.evaluation_gate,
jsonb_agg(jsonb_build_object(
'factor_id', f.factor_id,
'factor_version', f.factor_version,
'numeric_value', f.numeric_value,
'text_value', f.text_value,
'gate', f.gate,
'role', evidence.role
) ORDER BY f.factor_id) AS factor_features
FROM engine_history.decision_event d
JOIN engine_history.outcome_evaluation e ON e.decision_id = d.decision_id
JOIN engine_history.decision_factor_evidence evidence ON evidence.decision_id = d.decision_id
JOIN engine_history.factor_observation f ON f.factor_observation_id = evidence.factor_observation_id
GROUP BY d.decision_id, d.decision_key, d.decided_at, d.instrument_id, d.action,
d.gate, d.score, d.source_version, e.horizon_days, e.realized_return,
e.benchmark_return, e.excess_return, e.outcome_class, e.evaluation_gate;
@@ -0,0 +1,36 @@
-- V6__Add_Market_Time_Series.sql
-- Canonical PostgreSQL daily series for point-in-time factor calculations.
CREATE SCHEMA IF NOT EXISTS quantengine;
CREATE TABLE IF NOT EXISTS quantengine.price_history_daily (
ticker TEXT NOT NULL,
trade_date DATE NOT NULL,
open NUMERIC NOT NULL,
high NUMERIC NOT NULL,
low NUMERIC NOT NULL,
close NUMERIC NOT NULL,
volume BIGINT NOT NULL,
source TEXT NOT NULL,
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (ticker, trade_date),
CONSTRAINT price_history_daily_ohlc_order CHECK (high >= low AND high >= open AND high >= close AND low <= open AND low <= close),
CONSTRAINT price_history_daily_volume_nonnegative CHECK (volume >= 0)
);
CREATE INDEX IF NOT EXISTS idx_price_history_daily_date
ON quantengine.price_history_daily (trade_date DESC, ticker);
CREATE TABLE IF NOT EXISTS quantengine.macro_history_daily (
symbol TEXT NOT NULL,
trade_date DATE NOT NULL,
value NUMERIC NOT NULL,
source TEXT NOT NULL,
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (symbol, trade_date)
);
CREATE INDEX IF NOT EXISTS idx_macro_history_daily_date
ON quantengine.macro_history_daily (trade_date DESC, symbol);
@@ -0,0 +1,24 @@
using Dapper;
using QuantEngine.Core.Interfaces;
using QuantEngine.Infrastructure.Data;
namespace QuantEngine.Infrastructure.Repositories;
public sealed class LearningDatasetReader : ILearningDatasetReader
{
private readonly IDbConnectionFactory _connectionFactory;
public LearningDatasetReader(IDbConnectionFactory connectionFactory) => _connectionFactory = connectionFactory;
public async Task<IReadOnlyList<IDictionary<string, object?>>> ReadTrainingExamplesAsync(int limit = 1000)
{
if (limit is < 1 or > 10000)
throw new ArgumentOutOfRangeException(nameof(limit));
using var connection = _connectionFactory.CreateConnection();
var rows = await connection.QueryAsync(
"SELECT * FROM engine_history.training_example_v1 ORDER BY decided_at DESC LIMIT @Limit",
new { Limit = limit });
return rows.Select(row => (IDictionary<string, object?>)row).ToList();
}
}
@@ -0,0 +1,82 @@
using Dapper;
using QuantEngine.Core.Interfaces;
using QuantEngine.Infrastructure.Data;
namespace QuantEngine.Infrastructure.Repositories;
public sealed class NormalizedLearningStore : INormalizedLearningStore
{
private readonly IDbConnectionFactory _connectionFactory;
public NormalizedLearningStore(IDbConnectionFactory connectionFactory) => _connectionFactory = connectionFactory;
public async Task<Guid> AppendSourceObservationAsync(SourceObservationRecord record)
{
var id = Guid.NewGuid();
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(@"INSERT INTO engine_history.source_observation
(observation_id, observed_at, instrument_id, source_name, source_version, payload, provenance)
VALUES (@Id, @ObservedAt, @InstrumentId, @SourceName, @SourceVersion,
CAST(@PayloadJson AS jsonb), CAST(@ProvenanceJson AS jsonb))", new { Id = id, record.ObservedAt, record.InstrumentId, record.SourceName, record.SourceVersion, record.PayloadJson, record.ProvenanceJson });
return id;
}
public async Task<Guid> AppendFactorObservationAsync(FactorObservationRecord record)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(@"INSERT INTO engine_history.factor_observation
(factor_observation_id, observation_id, factor_id, factor_version, observed_at,
numeric_value, text_value, gate, provenance)
VALUES (@FactorObservationId, @ObservationId, @FactorId, @FactorVersion, @ObservedAt,
@NumericValue, @TextValue, @Gate, CAST(@ProvenanceJson AS jsonb))", new
{
record.FactorObservationId,
record.ObservationId,
record.FactorId,
record.FactorVersion,
record.ObservedAt,
record.NumericValue,
record.TextValue,
record.Gate,
record.ProvenanceJson
});
return record.FactorObservationId;
}
public async Task<Guid> AppendDecisionAsync(DecisionEventRecord record)
{
var id = Guid.NewGuid();
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(@"INSERT INTO engine_history.decision_event
(decision_id, decision_key, decided_at, instrument_id, action, gate, score,
source_version, trace, provenance)
VALUES (@Id, @DecisionKey, @DecidedAt, @InstrumentId, @Action, @Gate, @Score,
@SourceVersion, CAST(@TraceJson AS jsonb), CAST(@ProvenanceJson AS jsonb))", new { Id = id, record.DecisionKey, record.DecidedAt, record.InstrumentId, record.Action, record.Gate, record.Score, record.SourceVersion, record.TraceJson, record.ProvenanceJson });
return id;
}
public async Task AppendDecisionFactorEvidenceAsync(Guid decisionId, Guid factorObservationId, string role)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(@"INSERT INTO engine_history.decision_factor_evidence
(decision_id, factor_observation_id, role) VALUES (@DecisionId, @FactorObservationId, @Role)", new { decisionId, factorObservationId, role });
}
public async Task AppendOutcomeAsync(OutcomeEvaluationRecord record)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(@"INSERT INTO engine_history.outcome_evaluation
(decision_id, horizon_days, evaluated_at, realized_return, benchmark_return,
excess_return, outcome_class, evaluation_gate, provenance)
VALUES (@DecisionId, @HorizonDays, @EvaluatedAt, @RealizedReturn, @BenchmarkReturn,
@ExcessReturn, @OutcomeClass, @EvaluationGate, CAST(@ProvenanceJson AS jsonb))
ON CONFLICT (decision_id, horizon_days) DO UPDATE SET
evaluated_at = EXCLUDED.evaluated_at,
realized_return = EXCLUDED.realized_return,
benchmark_return = EXCLUDED.benchmark_return,
excess_return = EXCLUDED.excess_return,
outcome_class = EXCLUDED.outcome_class,
evaluation_gate = EXCLUDED.evaluation_gate,
provenance = EXCLUDED.provenance", record);
}
}
@@ -1,5 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -29,6 +31,7 @@ public class KisApiClient : IKisApiClient
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ITokenCache _tokenCache; private readonly ITokenCache _tokenCache;
private readonly ILogger<KisApiClient> _logger; private readonly ILogger<KisApiClient> _logger;
private static readonly ConcurrentDictionary<string, SemaphoreSlim> TokenLocks = new();
public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> logger) public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> logger)
{ {
@@ -138,61 +141,102 @@ public class KisApiClient : IKisApiClient
if (!string.IsNullOrEmpty(queryString)) if (!string.IsNullOrEmpty(queryString))
url += $"?{queryString}"; url += $"?{queryString}";
try int maxAttempts = 3;
{ int delayMs = 1000;
var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in headers)
request.Headers.Add(header.Key, header.Value);
var response = await _httpClient.SendAsync(request); for (int attempt = 1; attempt <= maxAttempts; attempt++)
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
return result ?? new Dictionary<string, object>();
}
catch (Exception ex)
{ {
_logger.LogError(ex, "KIS request failed: {Path} / {TrId}", path, trId); try
throw new InvalidOperationException($"KIS read-only request failed for {path} / {trId}.", ex); {
var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in headers)
request.Headers.Add(header.Key, header.Value);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
return result ?? new Dictionary<string, object>();
}
catch (Exception ex) when (attempt < maxAttempts)
{
_logger.LogWarning(ex, "KIS request failed on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms...", attempt, maxAttempts, delayMs);
await Task.Delay(delayMs);
delayMs *= 2;
}
catch (Exception ex)
{
_logger.LogError(ex, "KIS request failed after {MaxAttempts} attempts: {Path} / {TrId}", maxAttempts, path, trId);
throw new InvalidOperationException($"KIS read-only request failed for {path} / {trId} after {maxAttempts} attempts.", ex);
}
} }
throw new InvalidOperationException("Unreachable code in KIS client SendRequestAsync");
} }
private async Task<string> GetOrRefreshTokenAsync(KisCredentials creds) private async Task<string> GetOrRefreshTokenAsync(KisCredentials creds)
{ {
var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account); var tokenLock = TokenLocks.GetOrAdd(creds.Account, _ => new SemaphoreSlim(1, 1));
if (!string.IsNullOrEmpty(cachedToken)) await tokenLock.WaitAsync();
return cachedToken;
var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret };
try try
{ {
var response = await _httpClient.PostAsJsonAsync( // Re-check after acquiring the account lock. Another request may
$"{creds.Domain}/oauth2/tokenP", // have refreshed the shared cache while this request was waiting.
tokenRequest var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account);
); if (!string.IsNullOrEmpty(cachedToken))
response.EnsureSuccessStatusCode(); return cachedToken;
var tokenData = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>(); var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret };
if (tokenData == null) throw new InvalidOperationException("Token response body is empty");
if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null) int maxAttempts = 3;
throw new InvalidOperationException("No access_token in response"); int delayMs = 1000;
var accessToken = tokenObj.ToString()!;
var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null for (int attempt = 1; attempt <= maxAttempts; attempt++)
? expiresObj.ToString() {
: "86400"; try
var expiresInSec = int.TryParse(expiresInStr, out var seconds) ? seconds : 86400; {
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec); var response = await _httpClient.PostAsJsonAsync(
$"{creds.Domain}/oauth2/tokenP",
tokenRequest
);
response.EnsureSuccessStatusCode();
await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt); var tokenData = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
return accessToken; if (tokenData == null) throw new InvalidOperationException("Token response body is empty");
if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null)
throw new InvalidOperationException("No access_token in response");
var accessToken = tokenObj.ToString()!;
var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null
? expiresObj.ToString()
: "86400";
var expiresInSec = int.TryParse(expiresInStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)
? seconds
: 86400;
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec);
await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt);
_logger.LogInformation("KIS token refreshed for {Account}; expires at {ExpiresAtUtc}", creds.Account, expiresAt);
return accessToken;
}
catch (Exception ex) when (attempt < maxAttempts)
{
_logger.LogWarning(ex, "KIS token refresh failed on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms...", attempt, maxAttempts, delayMs);
await Task.Delay(delayMs);
delayMs *= 2;
}
catch (Exception ex)
{
_logger.LogError(ex, "KIS token refresh failed after {MaxAttempts} attempts", maxAttempts);
throw new InvalidOperationException($"KIS token refresh failed after {maxAttempts} attempts; check credentials and API availability.", ex);
}
}
throw new InvalidOperationException("Unreachable code in KIS client TokenRefresh");
} }
catch (Exception ex) finally
{ {
_logger.LogError(ex, "KIS token refresh failed"); tokenLock.Release();
throw new InvalidOperationException("KIS token refresh failed; check credentials and API availability.", ex);
} }
} }
@@ -1,4 +1,5 @@
using System; using System;
using System.Globalization;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dapper; using Dapper;
using QuantEngine.Core.Interfaces; using QuantEngine.Core.Interfaces;
@@ -31,7 +32,11 @@ namespace QuantEngine.Infrastructure.Services
if (token == null) if (token == null)
return null; return null;
var expiresAt = DateTime.Parse(token.ExpiresAt); DateTime expiresAt;
if (!DateTime.TryParse((string)token.ExpiresAt, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out expiresAt))
return null;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var refreshSkew = TimeSpan.FromMinutes(TokenRefreshSkewMinutes); var refreshSkew = TimeSpan.FromMinutes(TokenRefreshSkewMinutes);
@@ -1,256 +0,0 @@
using Bunit;
using MudBlazor;
using Xunit;
using QuantEngine.Web.Client.Pages;
using QuantEngine.Web.Client.Components;
namespace QuantEngine.Web.Tests;
/// <summary>
/// Unit tests for Dashboard component using bUnit
/// </summary>
public class DashboardComponentTests : TestContext
{
[Fact]
public void Dashboard_Renders_Without_Errors()
{
// Arrange & Act
var cut = RenderComponent<Dashboard>();
// Assert
cut.Markup.Should().Contain("관리자 대시보드");
}
[Fact]
public void Dashboard_Displays_KPI_Cards()
{
// Arrange & Act
var cut = RenderComponent<Dashboard>();
// Assert - Should have 4 KPI cards
cut.FindAll(".mud-paper").Count.Should().BeGreaterThanOrEqualTo(4);
cut.Markup.Should().Contain("총 수집 실행");
cut.Markup.Should().Contain("성공률");
cut.Markup.Should().Contain("최근 에러");
cut.Markup.Should().Contain("마지막 동기화");
}
[Fact]
public void Dashboard_Shows_System_Status()
{
// Arrange & Act
var cut = RenderComponent<Dashboard>();
// Assert
cut.Markup.Should().Contain("시스템 상태");
cut.Markup.Should().Contain("API 서버");
cut.Markup.Should().Contain("데이터베이스");
}
[Fact]
public void Dashboard_Has_Activity_Feed()
{
// Arrange & Act
var cut = RenderComponent<Dashboard>();
// Assert
cut.Markup.Should().Contain("최근 활동");
}
[Fact]
public void Dashboard_Has_Collections_Table()
{
// Arrange & Act
var cut = RenderComponent<Dashboard>();
// Assert
cut.Markup.Should().Contain("최근 데이터 수집 실행");
cut.Markup.Should().Contain("새로고침");
}
}
/// <summary>
/// Unit tests for FormField component
/// </summary>
public class FormFieldComponentTests : TestContext
{
[Fact]
public void FormField_Renders_Text_Input()
{
// Arrange
var parameters = new ComponentParameterCollection
{
{ "Label", "사용자명" },
{ "Type", "text" },
{ "Placeholder", "이름 입력" }
};
// Act
var cut = RenderComponent<FormField>(parameters);
// Assert
cut.Markup.Should().Contain("사용자명");
cut.Markup.Should().Contain("이름 입력");
}
[Fact]
public void FormField_Shows_Required_Indicator()
{
// Arrange
var parameters = new ComponentParameterCollection
{
{ "Label", "이메일" },
{ "Type", "email" },
{ "Required", true }
};
// Act
var cut = RenderComponent<FormField>(parameters);
// Assert
cut.Markup.Should().Contain("*");
}
[Fact]
public void FormField_Displays_Error_Message()
{
// Arrange
var parameters = new ComponentParameterCollection
{
{ "Label", "비밀번호" },
{ "Type", "password" },
{ "ErrorMessage", "최소 8자 이상 입력하세요" }
};
// Act
var cut = RenderComponent<FormField>(parameters);
// Assert
cut.Markup.Should().Contain("최소 8자 이상 입력하세요");
}
[Fact]
public void FormField_Shows_Help_Text()
{
// Arrange
var parameters = new ComponentParameterCollection
{
{ "Label", "핸드폰" },
{ "Type", "tel" },
{ "HelpText", "하이픈 없이 숫자만 입력하세요" }
};
// Act
var cut = RenderComponent<FormField>(parameters);
// Assert
cut.Markup.Should().Contain("하이픈 없이 숫자만 입력하세요");
}
}
/// <summary>
/// Unit tests for Portfolio component
/// </summary>
public class PortfolioComponentTests : TestContext
{
[Fact]
public void Portfolio_Renders_Without_Errors()
{
// Arrange & Act
var cut = RenderComponent<Portfolio>();
// Assert
cut.Markup.Should().Contain("포트폴리오");
}
[Fact]
public void Portfolio_Displays_Summary_Cards()
{
// Arrange & Act
var cut = RenderComponent<Portfolio>();
// Assert - Should have summary cards
cut.Markup.Should().Contain("총 평가액");
cut.Markup.Should().Contain("보유 종목");
cut.Markup.Should().Contain("수익률");
cut.Markup.Should().Contain("위험도");
}
[Fact]
public void Portfolio_Shows_Asset_Table()
{
// Arrange & Act
var cut = RenderComponent<Portfolio>();
// Assert
cut.Markup.Should().Contain("자산 구성");
cut.Markup.Should().Contain("종목/펀드명");
cut.Markup.Should().Contain("평가액");
}
[Fact]
public void Portfolio_Shows_Asset_Classification()
{
// Arrange & Act
var cut = RenderComponent<Portfolio>();
// Assert
cut.Markup.Should().Contain("자산 분류");
cut.Markup.Should().Contain("대형주");
cut.Markup.Should().Contain("중형주");
}
[Fact]
public void Portfolio_Shows_Trading_History()
{
// Arrange & Act
var cut = RenderComponent<Portfolio>();
// Assert
cut.Markup.Should().Contain("거래 이력");
cut.Markup.Should().Contain("구분");
cut.Markup.Should().Contain("금액");
}
}
/// <summary>
/// Unit tests for NavMenu component
/// </summary>
public class NavMenuComponentTests : TestContext
{
[Fact]
public void NavMenu_Renders_Navigation_Links()
{
// Arrange & Act
var cut = RenderComponent<NavMenu>();
// Assert
cut.Markup.Should().Contain("대시보드");
cut.Markup.Should().Contain("관리");
cut.Markup.Should().Contain("운영");
}
[Fact]
public void NavMenu_Has_Admin_Section()
{
// Arrange & Act
var cut = RenderComponent<NavMenu>();
// Assert
cut.Markup.Should().Contain("사용자 관리");
cut.Markup.Should().Contain("데이터 수집");
cut.Markup.Should().Contain("설정");
}
[Fact]
public void NavMenu_Has_Help_Section()
{
// Arrange & Act
var cut = RenderComponent<NavMenu>();
// Assert
cut.Markup.Should().Contain("도움말");
cut.Markup.Should().Contain("문서");
cut.Markup.Should().Contain("API");
}
}
@@ -1,61 +0,0 @@
@namespace QuantEngine.Web.Client.Components
@inject IDialogService DialogService
@code {
public static async Task<bool> Show(IDialogService dialogService, string title, string message, string confirmText = "확인", string cancelText = "취소")
{
var options = new DialogOptions
{
CloseButton = false,
MaxWidth = MaxWidth.Small,
FullWidth = true,
BackdropClick = false
};
var parameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Title, title },
{ x => x.Message, message },
{ x => x.ConfirmText, confirmText },
{ x => x.CancelText, cancelText }
};
var dialog = await dialogService.ShowAsync<ConfirmDialog>(title, parameters, options);
var result = await dialog.Result;
return !result.Canceled && (bool?)result.Data == true;
}
}
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudText Typo="Typo.h6">@Title</MudText>
<MudText Typo="Typo.body2">@Message</MudText>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Color="Color.Default">@CancelText</MudButton>
<MudButton OnClick="Confirm" Color="Color.Primary" Variant="Variant.Filled">@ConfirmText</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; }
[Parameter]
public string Title { get; set; } = "확인";
[Parameter]
public string Message { get; set; } = "";
[Parameter]
public string ConfirmText { get; set; } = "확인";
[Parameter]
public string CancelText { get; set; } = "취소";
private void Confirm() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
}
@@ -1,125 +0,0 @@
@namespace QuantEngine.Web.Client.Components
<MudStack Spacing="2" Class="form-field">
<label class="form-label">
@Label
@if (Required)
{
<span class="text-error">*</span>
}
</label>
@switch (Type)
{
case "text":
case "email":
case "password":
case "number":
<MudTextField T="string"
Value="@Value"
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
Variant="Variant.Outlined"
FullWidth="true"
Placeholder="@Placeholder"
Type="@Type"
Required="@Required"
ErrorText="@ErrorMessage" />
break;
case "textarea":
<MudTextField T="string"
Value="@Value"
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
Variant="Variant.Outlined"
FullWidth="true"
Placeholder="@Placeholder"
Lines="5"
Required="@Required"
ErrorText="@ErrorMessage" />
break;
case "select":
<MudSelect T="string"
Value="@Value"
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
Variant="Variant.Outlined"
FullWidth="true"
Required="@Required">
@foreach (var option in Options)
{
<MudSelectItem T="string" Value="@option">@option</MudSelectItem>
}
</MudSelect>
break;
case "checkbox":
<MudCheckBox T="bool"
Checked="@(Value == "true")"
CheckedChanged="@((bool v) => ValueChanged.InvokeAsync(v ? "true" : "false"))">
@Label
</MudCheckBox>
break;
case "date":
<MudTextField T="string"
Value="@Value"
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
Variant="Variant.Outlined"
FullWidth="true"
Type="date"
Required="@Required" />
break;
}
@if (!string.IsNullOrEmpty(HelpText))
{
<MudText Typo="Typo.caption" Class="text-muted">@HelpText</MudText>
}
</MudStack>
@code {
[Parameter]
public string Label { get; set; } = "";
[Parameter]
public string Type { get; set; } = "text";
[Parameter]
public string Value { get; set; } = "";
[Parameter]
public EventCallback<string> ValueChanged { get; set; }
[Parameter]
public string Placeholder { get; set; } = "";
[Parameter]
public bool Required { get; set; } = false;
[Parameter]
public string ErrorMessage { get; set; } = "";
[Parameter]
public string HelpText { get; set; } = "";
[Parameter]
public List<string> Options { get; set; } = new();
}
<style>
.form-field {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-weight: 500;
font-size: 0.875rem;
color: var(--mud-palette-text-primary);
margin-bottom: 0.5rem;
}
.form-label .text-error {
color: var(--mud-palette-error);
}
</style>
@@ -1,249 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.JSInterop;
using QuantEngine.Web.Client.Services;
namespace QuantEngine.Web.Client.Infrastructure
{
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly LocalStorageService _localStorage;
private readonly HttpClient _http;
private readonly IJSRuntime _jsRuntime;
private readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
private const string TokenKey = "quant_admin_access_token";
private const string UsernameKey = "quant_admin_username";
private const string RoleKey = "quant_admin_role";
private const string RememberUsernameKey = "quant_admin_remember_username";
private AuthenticationState? _cachedState;
public CustomAuthenticationStateProvider(LocalStorageService localStorage, HttpClient http, IJSRuntime jsRuntime)
{
_localStorage = localStorage;
_http = http;
_jsRuntime = jsRuntime;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (_cachedState != null && _cachedState.User.Identity?.IsAuthenticated == true)
{
Console.WriteLine("[Auth] Returning cached authentication state");
return _cachedState;
}
try
{
Console.WriteLine("[Auth] GetAuthenticationStateAsync called");
// Primary: Try to validate via /api/auth/me
// This works with both cookies (automatic) and Bearer tokens
try
{
Console.WriteLine("[Auth] Attempting validation via /api/auth/me (cookie or Bearer)...");
// BaseAddress is always set to HostEnvironment.BaseAddress by DI.
// Never fall back to a hardcoded port — it breaks in production.
var meUrl = "api/auth/me";
var requestUri = _http.BaseAddress == null ? new Uri($"http://localhost:5265/{meUrl}") : new Uri(_http.BaseAddress, meUrl);
Console.WriteLine($"[Auth] /api/auth/me URL: {requestUri}");
var meResponse = await _http.GetAsync(requestUri);
Console.WriteLine($"[Auth] /api/auth/me status: {meResponse.StatusCode}");
if (meResponse.IsSuccessStatusCode)
{
var json = await meResponse.Content.ReadAsStringAsync();
Console.WriteLine($"[Auth] Response JSON: {json}");
var meData = System.Text.Json.JsonDocument.Parse(json).RootElement;
var authenticated = meData.TryGetProperty("authenticated", out var authProp) && authProp.GetBoolean();
var username = meData.TryGetProperty("username", out var userProp) ? userProp.GetString() : null;
var role = meData.TryGetProperty("role", out var roleProp) ? roleProp.GetString() : "Admin";
Console.WriteLine($"[Auth] Parsed: authenticated={authenticated}, username={username}, role={role}");
if (authenticated && !string.IsNullOrWhiteSpace(username))
{
Console.WriteLine($"[Auth] ✅ SUCCESS: Authenticated as {username}");
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, role ?? "Admin")
}, "QuantAdminAuth");
var state = new AuthenticationState(new ClaimsPrincipal(identity));
_cachedState = state;
return state;
}
else
{
Console.WriteLine($"[Auth] Parsing failed: authenticated={authenticated}, username={username}");
}
}
else
{
Console.WriteLine($"[Auth] /api/auth/me returned {meResponse.StatusCode}");
if (IsLocalhost())
{
Console.WriteLine("[Auth] Dev SSR fallback: allowing admin authentication on 401");
return GetDevAdminState();
}
}
}
catch (Exception meEx)
{
Console.WriteLine($"[Auth] /api/auth/me failed: {meEx.Message}");
Console.WriteLine($"[Auth] Exception: {meEx}");
if (IsLocalhost())
{
Console.WriteLine("[Auth] Dev SSR fallback: allowing admin authentication on exception");
return GetDevAdminState();
}
}
// Fallback: Try to read from localStorage
Console.WriteLine("[Auth] Fallback: checking localStorage...");
try
{
string token = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", TokenKey);
string username = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", UsernameKey);
string role = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", RoleKey);
Console.WriteLine($"[Auth] localStorage: token={!string.IsNullOrWhiteSpace(token)}, username={username}");
if (!string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(username))
{
var meUrl = "api/auth/me";
var requestUri = _http.BaseAddress == null ? new Uri($"http://localhost:5265/{meUrl}") : new Uri(_http.BaseAddress, meUrl);
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await _http.SendAsync(request);
if (response.IsSuccessStatusCode)
{
Console.WriteLine($"[Auth] ✅ localStorage token validated: {username}");
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, role ?? "Admin")
}, "QuantAdminAuth");
var state = new AuthenticationState(new ClaimsPrincipal(identity));
_cachedState = state;
return state;
}
}
}
catch (Exception jsEx)
{
Console.WriteLine($"[Auth] localStorage fallback failed: {jsEx.Message}");
}
Console.WriteLine("[Auth] ❌ Not authenticated");
}
catch (Exception ex)
{
Console.WriteLine($"[Auth] Unexpected error: {ex.Message}");
}
_cachedState = new AuthenticationState(_anonymous);
return _cachedState;
}
public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role)
{
await MarkUserAsAuthenticatedAsync(username, accessToken, role, rememberUsername: true);
}
public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role, bool rememberUsername)
{
await _localStorage.SetAsync(TokenKey, accessToken);
if (rememberUsername)
{
await _localStorage.SetAsync(UsernameKey, username);
await _localStorage.SetAsync(RememberUsernameKey, true);
}
else
{
await _localStorage.DeleteAsync(UsernameKey);
await _localStorage.SetAsync(RememberUsernameKey, false);
}
await _localStorage.SetAsync(RoleKey, role);
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, role)
}, "QuantAdminAuth");
var user = new ClaimsPrincipal(identity);
var state = new AuthenticationState(user);
_cachedState = state;
NotifyAuthenticationStateChanged(Task.FromResult(state));
}
public async Task MarkUserAsLoggedOutAsync()
{
await _localStorage.DeleteAsync(TokenKey);
await _localStorage.DeleteAsync(RoleKey);
var rememberUsername = await _localStorage.GetAsync<bool>(RememberUsernameKey);
if (!rememberUsername)
{
await _localStorage.DeleteAsync(UsernameKey);
}
_cachedState = new AuthenticationState(_anonymous);
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
}
public async Task LogoutFromServerAsync()
{
var token = await _localStorage.GetAsync<string>(TokenKey);
if (!string.IsNullOrWhiteSpace(token))
{
try
{
var request = new HttpRequestMessage(HttpMethod.Post, "api/auth/logout");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
await _http.SendAsync(request);
}
catch
{
// Best-effort server revocation; always clear local state.
}
}
await MarkUserAsLoggedOutAsync();
}
public async Task<string?> GetRememberedUsernameAsync()
{
var rememberUsername = await _localStorage.GetAsync<bool>(RememberUsernameKey);
if (!rememberUsername)
{
return null;
}
return await _localStorage.GetAsync<string>(UsernameKey);
}
private bool IsLocalhost()
{
return _http.BaseAddress == null || _http.BaseAddress.Host == "localhost" || _http.BaseAddress.Host == "127.0.0.1";
}
private AuthenticationState GetDevAdminState()
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, "admin"),
new Claim(ClaimTypes.Role, "Admin")
}, "QuantAdminAuth");
var state = new AuthenticationState(new ClaimsPrincipal(identity));
_cachedState = state;
return state;
}
}
}
@@ -1,20 +0,0 @@
@inherits LayoutComponentBase
@rendermode InteractiveWebAssembly
<style>
:global(body) {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
:global(html, body, #app) {
width: 100%;
height: 100%;
}
</style>
@Body
@code {
}
@@ -1,260 +0,0 @@
/* QuantEngine AuthLayout Styles */
.auth-container {
display: flex;
min-height: 100vh;
background: linear-gradient(135deg, var(--mud-palette-primary) 0%, var(--mud-palette-primary-dark) 100%);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* Left Panel - Branding */
.auth-left-panel {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding: 3rem;
color: white;
position: relative;
}
.auth-branding {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
flex: 1;
justify-content: center;
}
.auth-logo {
margin-bottom: 2rem;
animation: float 3s ease-in-out infinite;
}
.auth-logo ::deep svg {
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
font-size: 80px;
color: white;
}
.auth-title {
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: 1px;
}
.auth-subtitle {
opacity: 0.9;
font-size: 1.1rem;
max-width: 300px;
}
.auth-features {
margin-top: 3rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
align-items: flex-start;
width: 100%;
max-width: 300px;
}
.auth-feature {
display: flex;
align-items: center;
gap: 1rem;
opacity: 0.95;
}
.auth-feature ::deep svg {
font-size: 24px;
color: #4caf50;
flex-shrink: 0;
}
.auth-theme-toggle {
position: absolute;
top: 2rem;
right: 2rem;
}
.auth-theme-toggle ::deep button {
color: white;
transition: transform 0.2s ease;
}
.auth-theme-toggle ::deep button:hover {
transform: scale(1.1);
}
/* Right Panel - Auth Content */
.auth-right-panel {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2rem;
background: var(--mud-palette-background);
position: relative;
}
.auth-mobile-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--mud-palette-divider);
}
.auth-mobile-header ::deep .mud-icon {
color: var(--mud-palette-primary);
}
.auth-content {
width: 100%;
max-width: 450px;
}
.auth-content ::deep .mud-card {
background: var(--mud-palette-surface);
border: 1px solid var(--mud-palette-divider);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.auth-content ::deep .mud-form-control {
margin-bottom: 1.5rem;
}
.auth-content ::deep .mud-button {
text-transform: none;
font-weight: 600;
padding: 0.75rem 1.5rem;
}
.auth-content ::deep .mud-button-root {
border-radius: 0.4rem;
}
/* Footer */
.auth-footer {
position: absolute;
bottom: 2rem;
text-align: center;
width: 100%;
padding: 1rem 2rem;
border-top: 1px solid var(--mud-palette-divider);
}
.auth-footer-text {
display: block;
color: var(--mud-palette-text-secondary);
margin-bottom: 0.5rem;
}
.auth-footer-links {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
}
.auth-footer-links ::deep a {
color: var(--mud-palette-primary);
text-decoration: none;
transition: color 0.2s ease;
}
.auth-footer-links ::deep a:hover {
color: var(--mud-palette-primary-dark);
text-decoration: underline;
}
/* Responsive */
@media (max-width: 960px) {
.auth-container {
flex-direction: column;
}
.auth-left-panel {
padding: 2rem;
min-height: 40vh;
}
.auth-right-panel {
padding: 3rem 2rem 5rem;
min-height: 60vh;
}
.auth-mobile-header {
display: flex;
}
.auth-footer {
bottom: 1rem;
padding: 1rem;
}
}
@media (max-width: 600px) {
.auth-right-panel {
padding: 2rem 1rem 5rem;
}
.auth-content {
max-width: 100%;
}
.auth-features {
max-width: 100%;
}
.auth-footer {
position: static;
padding: 1rem;
border-top: 1px solid var(--mud-palette-divider);
margin-top: 3rem;
}
}
/* Animation */
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
/* Dark Mode */
[data-theme="dark"] .auth-container {
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
}
[data-theme="dark"] .auth-left-panel {
color: #f0f0f0;
}
[data-theme="dark"] .auth-right-panel {
background: #121212;
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
.auth-logo {
animation: none;
}
.auth-theme-toggle ::deep button {
transition: none;
}
.auth-footer-links ::deep a {
transition: none;
}
}
@@ -1,16 +0,0 @@
@inherits LayoutComponentBase
@Body
<style>
:global(html, body) {
height: 100%;
margin: 0;
padding: 0;
}
:global(#app) {
display: flex;
min-height: 100vh;
}
</style>
@@ -1,154 +0,0 @@
@inherits LayoutComponentBase
@using QuantEngine.Web.Client.Theme
@inject HttpClient Http
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<!-- ✅ MudBlazor Providers (Required for Interactive WebAssembly) -->
<MudThemeProvider Theme="@_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
<!-- Top Navigation Bar -->
<MudAppBar Elevation="1" Dense="false" Color="Color.Surface" Class="mud-appbar-dense">
<MudHidden Breakpoint="Breakpoint.SmAndUp" Invert="true">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@(() => navOpen = !navOpen)" />
</MudHidden>
<MudText Typo="Typo.h6" Class="ml-2">
<MudIcon Icon="@Icons.Material.Filled.Dashboard" Class="me-2" />
QuantEngine
</MudText>
<MudSpacer />
<!-- User Menu -->
<AuthorizeView Context="authContext">
<Authorized>
<MudMenu AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight" Class="ml-2">
<ActivatorContent>
<MudAvatar Color="Color.Primary" Image="@GetUserInitials()" Class="cursor-pointer">
@GetFirstLetter(authContext.User.Identity?.Name)
</MudAvatar>
</ActivatorContent>
<ChildContent>
<MudMenuItem>
<MudText Typo="Typo.body2">
<strong>@authContext.User.Identity?.Name</strong>
</MudText>
</MudMenuItem>
<MudDivider />
<MudMenuItem href="/profile">
<MudIcon Icon="@Icons.Material.Filled.Person" Class="mr-2" Size="Size.Small" />
프로필
</MudMenuItem>
<MudMenuItem href="/settings">
<MudIcon Icon="@Icons.Material.Filled.Settings" Class="mr-2" Size="Size.Small" />
설정
</MudMenuItem>
<MudDivider />
<MudMenuItem OnClick="HandleLogoutAsync">
<MudIcon Icon="@Icons.Material.Filled.Logout" Class="mr-2" Size="Size.Small" Color="Color.Error" />
<MudText Color="Color.Error">로그아웃</MudText>
</MudMenuItem>
</ChildContent>
</MudMenu>
</Authorized>
</AuthorizeView>
</MudAppBar>
<!-- Sidebar Navigation -->
<MudDrawer Open="@navOpen" Variant="DrawerVariant.Responsive" Elevation="1" FixedOpen="@fixedOpen">
<MudDrawerHeader Class="d-flex align-center justify-space-between">
<MudText Typo="Typo.h6" Class="px-2">메뉴</MudText>
<MudHidden Breakpoint="Breakpoint.Md" Invert="true">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
OnClick="ToggleDrawer"
Class="mx-1" />
</MudHidden>
</MudDrawerHeader>
<MudNavMenu>
<NavMenu />
</MudNavMenu>
<!-- Drawer Footer -->
<div class="mud-drawer-footer">
<MudDivider />
<div style="padding: 16px;">
<MudText Typo="Typo.caption">
<strong>QuantEngine</strong>
</MudText>
<MudText Typo="Typo.caption">
v@appVersion
</MudText>
<MudText Typo="Typo.caption" Class="mt-2">
배포: @buildTime
</MudText>
</div>
</div>
</MudDrawer>
<!-- Main Content Area -->
<MudMainContent Class="mud-main-content-enhanced">
<MudContainer MaxWidth="MaxWidth.False" Class="pa-6">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
@code {
private MudTheme _theme = AppTheme.LightTheme;
private bool navOpen = true;
private bool fixedOpen = true;
private string appVersion = "Local Debug";
private string buildTime = "N/A";
protected override async Task OnInitializedAsync()
{
try
{
var versionInfo = await Http.GetFromJsonAsync<VersionInfo>("version.json");
if (versionInfo != null)
{
appVersion = versionInfo.Version ?? "Local Debug";
buildTime = versionInfo.Built ?? "N/A";
}
}
catch
{
}
await base.OnInitializedAsync();
}
private void ToggleDrawer()
{
navOpen = !navOpen;
}
private async Task HandleLogoutAsync()
{
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
await customProvider.LogoutFromServerAsync();
NavigationManager.NavigateTo("/Account/Login", forceLoad: true);
}
private string GetFirstLetter(string? name)
{
return string.IsNullOrEmpty(name) ? "?" : name[0].ToString().ToUpper();
}
private string GetUserInitials()
{
return string.Empty;
}
private class VersionInfo
{
public string? Version { get; set; }
public string? Built { get; set; }
}
}
@@ -1,105 +0,0 @@
/* QuantEngine MainLayout Styles */
/* AppBar Enhancements */
.mud-appbar-dense {
padding: 0 1rem;
}
.mud-appbar-dense ::deep .mud-appbar-section-center {
flex: 1;
}
/* Avatar Styling */
::deep .mud-avatar {
cursor: pointer;
transition: transform 0.2s ease;
}
::deep .mud-avatar:hover {
transform: scale(1.05);
}
/* Drawer Footer */
.mud-drawer-footer {
position: absolute;
bottom: 0;
width: 100%;
background: var(--mud-palette-surface);
}
/* Main Content Area */
.mud-main-content-enhanced {
min-height: 100vh;
background: var(--mud-palette-background);
transition: background-color 0.3s ease;
}
/* Navigation Menu Styles */
.mud-navmenu {
padding: 1rem 0;
}
.mud-navmenu ::deep .mud-nav-item {
padding: 0.5rem 0;
margin: 0.25rem 0;
}
.mud-navmenu ::deep .mud-nav-link {
border-radius: 0.4rem;
margin: 0 0.5rem;
transition: all 0.2s ease;
}
.mud-navmenu ::deep .mud-nav-link:hover {
background-color: var(--mud-palette-action-default-hover);
}
.mud-navmenu ::deep .mud-nav-link.mud-ripple-nav-link-active {
background-color: var(--mud-palette-primary-lighten);
color: var(--mud-palette-primary);
font-weight: 600;
}
/* Responsive Drawer */
@media (max-width: 599px) {
.mud-drawer-content {
width: 100% !important;
}
.mud-drawer-footer {
position: relative;
}
}
@media (min-width: 600px) {
.mud-drawer-footer {
position: absolute;
}
}
/* Error UI */
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
/* Dark Mode Transitions */
* {
transition: background-color 0.3s ease, color 0.3s ease;
}
@@ -1,18 +0,0 @@
<MudNavMenu>
<!-- Main Navigation -->
<MudNavLink Href="/dashboard" Icon="@Icons.Material.Filled.Dashboard" Match="NavLinkMatch.All">
대시보드
</MudNavLink>
<!-- Admin Section -->
<MudNavGroup Title="관리" Icon="@Icons.Material.Filled.AdminPanelSettings" Expanded="true">
<MudNavLink Href="/users" Icon="@Icons.Material.Filled.People">사용자 관리</MudNavLink>
<MudNavLink Href="/collection" Icon="@Icons.Material.Filled.CloudDownload">데이터 수집</MudNavLink>
<MudNavLink Href="/monitoring" Icon="@Icons.Material.Filled.Timeline">수집 모니터링</MudNavLink>
</MudNavGroup>
<!-- Operations -->
<MudNavLink Href="/operations" Icon="@Icons.Material.Filled.PlaylistPlay" Match="NavLinkMatch.Prefix">
운영 리포트
</MudNavLink>
</MudNavMenu>
@@ -1,105 +0,0 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}
@@ -1,155 +0,0 @@
@page "/collection"
@attribute [Authorize]
@using QuantEngine.Web.Client.Services
@inject ApiClient ApiClient
@inject ILogger<Collection> Logger
<PageTitle>QuantEngine - Collection</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">Data Collection</MudText>
<MudText Typo="Typo.body2" Class="mb-4">KIS API data collection dashboard. API-first로만 동작합니다.</MudText>
<MudStack Row="true" Spacing="2" Class="mb-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@StartCollectionAsync" Disabled="@IsProcessing">
@(IsProcessing ? "Running..." : "Start Collection")
</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="@RefreshAsync" Disabled="@IsProcessing">Refresh</MudButton>
</MudStack>
@if (IsLoading)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-4" />
}
else if (DashboardState != null)
{
<MudGrid Spacing="2" Class="mb-4">
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">Last Run</MudText>
<MudText Typo="Typo.h6">@(DashboardState.LastRunStatus ?? "N/A")</MudText>
<MudText Typo="Typo.body2">@(DashboardState.LastFinishedAt ?? "Not finished")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">Total Snapshots</MudText>
<MudText Typo="Typo.h6">@DashboardState.TotalSnapshots</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">Total Errors</MudText>
<MudText Typo="Typo.h6">@DashboardState.TotalErrors</MudText>
</MudPaper>
</MudItem>
</MudGrid>
@if (DashboardState.RecentErrors.Count > 0)
{
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Recent Errors</MudText>
<MudTable Items="@DashboardState.RecentErrors" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Source</MudTh>
<MudTh>Kind</MudTh>
<MudTh>Ticker</MudTh>
<MudTh>Message</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Source">@context.SourceName</MudTd>
<MudTd DataLabel="Kind">@context.ErrorKind</MudTd>
<MudTd DataLabel="Ticker">@context.Ticker</MudTd>
<MudTd DataLabel="Message">@context.ErrorMessage</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
}
@if (RecentRuns != null && RecentRuns.Count > 0)
{
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Recent Runs</MudText>
<MudTable Items="@RecentRuns" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Run ID</MudTh>
<MudTh>Status</MudTh>
<MudTh>Started</MudTh>
<MudTh>Finished</MudTh>
<MudTh>Snapshots</MudTh>
<MudTh>Errors</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Run ID" Style="font-family: monospace; font-size: 12px;">@context.RunId</MudTd>
<MudTd DataLabel="Status">@context.Status</MudTd>
<MudTd DataLabel="Started">@context.StartedAt</MudTd>
<MudTd DataLabel="Finished">@context.FinishedAt</MudTd>
<MudTd DataLabel="Snapshots">@context.TotalSnapshots</MudTd>
<MudTd DataLabel="Errors">@context.TotalErrors</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
}
}
@code {
private CollectionDashboardStateDto? DashboardState;
private List<CollectionRunDto>? RecentRuns;
private bool IsLoading = true;
private bool IsProcessing = false;
protected override async Task OnInitializedAsync()
{
await LoadDashboardStateAsync();
}
private async Task LoadDashboardStateAsync()
{
IsLoading = true;
try
{
// Parallelize API calls to avoid sequential RTT bottlenecks
var stateTask = ApiClient.GetCollectionStateAsync();
var runsTask = ApiClient.GetCollectionRunsAsync(10);
await Task.WhenAll(stateTask, runsTask);
DashboardState = await stateTask;
var runsResponse = await runsTask;
RecentRuns = runsResponse?.Runs ?? new();
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading dashboard");
}
finally
{
IsLoading = false;
}
}
private async Task StartCollectionAsync()
{
IsProcessing = true;
try
{
var result = await ApiClient.StartCollectionRunAsync();
if (result != null)
{
await LoadDashboardStateAsync();
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error starting collection");
}
finally
{
IsProcessing = false;
}
}
private async Task RefreshAsync()
{
await LoadDashboardStateAsync();
}
}
@@ -1,342 +0,0 @@
@page "/dashboard"
@rendermode InteractiveWebAssembly
@using QuantEngine.Core.Infrastructure
@using Microsoft.AspNetCore.Components.Authorization
@inject HttpClient Http
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavManager
<PageTitle>QuantEngine - Admin Dashboard</PageTitle>
<!-- Page Header -->
<div class="mb-6">
<MudText Typo="Typo.h4" Class="mb-2">관리자 대시보드</MudText>
<MudText Typo="Typo.body1" Class="text-muted">시스템 현황 및 데이터 수집 모니터링</MudText>
</div>
<!-- KPI Cards -->
<MudGrid Spacing="3" Class="mb-6">
<!-- Total Runs -->
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<div class="d-flex justify-content-between align-items-start">
<div>
<MudText Typo="Typo.caption" Class="text-muted mb-1">총 수집 실행</MudText>
<MudText Typo="Typo.h5" Class="text-primary">@TotalRuns</MudText>
<MudText Typo="Typo.body2" Class="text-muted mt-2">
<MudIcon Icon="@Icons.Material.Filled.TrendingUp" Size="Size.Small" Style="color: #4caf50;" />
이번 주 +@WeeklyRuns
</MudText>
</div>
<MudIcon Icon="@Icons.Material.Filled.PlayCircleOutline" Size="Size.Large" Class="text-primary" Style="opacity: 0.3;" />
</div>
</MudPaper>
</MudItem>
<!-- Success Rate -->
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<div class="d-flex justify-content-between align-items-start">
<div>
<MudText Typo="Typo.caption" Class="text-muted mb-1">성공률</MudText>
<MudText Typo="Typo.h5" Class="text-success">@SuccessRate%</MudText>
<MudText Typo="Typo.body2" Class="text-muted mt-2">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Size="Size.Small" Style="color: #4caf50;" />
최근 30일
</MudText>
</div>
<MudIcon Icon="@Icons.Material.Filled.Assessment" Size="Size.Large" Class="text-success" Style="opacity: 0.3;" />
</div>
</MudPaper>
</MudItem>
<!-- Recent Errors -->
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<div class="d-flex justify-content-between align-items-start">
<div>
<MudText Typo="Typo.caption" Class="text-muted mb-1">최근 에러</MudText>
<MudText Typo="Typo.h5" Class="text-error">@RecentErrors</MudText>
<MudText Typo="Typo.body2" Class="text-muted mt-2">
<MudIcon Icon="@Icons.Material.Filled.ErrorOutline" Size="Size.Small" Style="color: #f44336;" />
지난 7일
</MudText>
</div>
<MudIcon Icon="@Icons.Material.Filled.WarningAmber" Size="Size.Large" Class="text-error" Style="opacity: 0.3;" />
</div>
</MudPaper>
</MudItem>
<!-- Last Sync -->
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<div class="d-flex justify-content-between align-items-start">
<div>
<MudText Typo="Typo.caption" Class="text-muted mb-1">마지막 동기화</MudText>
<MudText Typo="Typo.h5">@LastSyncTime</MudText>
<MudText Typo="Typo.body2" Class="text-muted mt-2">
<MudChip T="string" Label="true" Size="Size.Small"
Color="@(IsLastSyncSuccess ? Color.Success : Color.Warning)"
Variant="Variant.Filled">
@(IsLastSyncSuccess ? "성공" : "경고")
</MudChip>
</MudText>
</div>
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Large" Class="text-secondary" Style="opacity: 0.3;" />
</div>
</MudPaper>
</MudItem>
</MudGrid>
<!-- Main Content Grid -->
<MudGrid Spacing="3" Class="mb-6">
<!-- Recent Activity Feed -->
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-4">최근 활동</MudText>
@if (RecentActivities.Count == 0)
{
<MudAlert Severity="Severity.Info">활동 기록이 없습니다.</MudAlert>
}
else
{
<MudStack Spacing="2">
@foreach (var activity in RecentActivities)
{
<div class="d-flex gap-3 pa-2" style="border-left: 3px solid @GetActivityColor(activity.Type); padding-left: 12px;">
<MudIcon Icon="@GetActivityIcon(activity.Type)" Size="Size.Medium" Color="@GetActivityColorEnum(activity.Type)" />
<div style="flex: 1;">
<MudText Typo="Typo.body2" Class="font-weight-500">@activity.Title</MudText>
<MudText Typo="Typo.caption" Class="text-muted">@activity.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</MudText>
<MudText Typo="Typo.body2" Class="mt-1">@activity.Description</MudText>
</div>
</div>
}
</MudStack>
}
</MudPaper>
</MudItem>
<!-- System Status -->
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-4">시스템 상태</MudText>
<MudStack Spacing="2">
<div class="d-flex justify-content-between align-items-center">
<MudText Typo="Typo.body2">API 서버</MudText>
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">온라인</MudChip>
</div>
<div class="d-flex justify-content-between align-items-center">
<MudText Typo="Typo.body2">데이터베이스</MudText>
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">연결됨</MudChip>
</div>
<div class="d-flex justify-content-between align-items-center">
<MudText Typo="Typo.body2">KIS API</MudText>
<MudChip T="string" Label="true" Size="Size.Small" Color="@(KisApiStatus ? Color.Success : Color.Warning)" Variant="Variant.Filled">
@(KisApiStatus ? "활성" : "비활성")
</MudChip>
</div>
<MudDivider Class="my-2" />
<MudText Typo="Typo.caption" Class="text-muted">마지막 점검: @SystemCheckTime</MudText>
</MudStack>
</MudPaper>
</MudItem>
</MudGrid>
<!-- Collections Table -->
<MudPaper Class="pa-4" Elevation="1">
<div class="d-flex justify-content-between align-items-center mb-4">
<MudText Typo="Typo.h6">최근 데이터 수집 실행</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" OnClick="RefreshData">
<MudIcon Icon="@Icons.Material.Filled.Refresh" Size="Size.Small" Class="mr-2" />
새로고침
</MudButton>
</div>
@if (Sections.Count == 0)
{
<MudAlert Severity="Severity.Info">데이터 수집 기록이 없습니다.</MudAlert>
}
else
{
<MudTable Items="@Sections" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>이름</MudTh>
<MudTh>상태</MudTh>
<MudTh>시작 시간</MudTh>
<MudTh>작업</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">
<MudText Typo="Typo.body2">@context.Name</MudText>
</MudTd>
<MudTd DataLabel="Status">
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">
@context.Title
</MudChip>
</MudTd>
<MudTd DataLabel="Timestamp">
<MudText Typo="Typo.body2">@context.Preview</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary">상세</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudPaper>
<style>
.mud-card-kpi {
border-radius: 8px !important;
transition: all 0.3s ease;
}
.mud-card-kpi:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
transform: translateY(-2px);
}
.text-primary {
color: var(--mud-palette-primary) !important;
}
.text-success {
color: var(--mud-palette-success) !important;
}
.text-error {
color: var(--mud-palette-error) !important;
}
.text-muted {
color: var(--mud-palette-text-secondary) !important;
}
.font-weight-500 {
font-weight: 500;
}
.gap-3 {
gap: 1rem;
}
</style>
@code {
private readonly List<OperationalReportSection> Sections = new();
private readonly List<ActivityLog> RecentActivities = new();
// KPI values
private int TotalRuns = 47;
private int WeeklyRuns = 12;
private int SuccessRate = 94;
private int RecentErrors = 3;
private string LastSyncTime = "2분 전";
private bool IsLastSyncSuccess = true;
private bool KisApiStatus = true;
private string SystemCheckTime = DateTime.Now.ToString("HH:mm:ss");
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!(authState.User.Identity?.IsAuthenticated ?? false))
{
NavManager.NavigateTo("/Account/Login", forceLoad: true);
return;
}
try
{
var report = await Http.GetFromJsonAsync<OperationalReportData>("api/operational-report");
if (report != null)
{
Sections.Clear();
Sections.AddRange(report.Sections);
}
}
catch
{
// Handle error silently
}
LoadRecentActivities();
}
private void LoadRecentActivities()
{
RecentActivities.Clear();
RecentActivities.AddRange(new[]
{
new ActivityLog
{
Type = "success",
Title = "데이터 수집 완료",
Description = "삼성전자(005930) 주가 데이터 수집 성공",
Timestamp = DateTime.Now.AddMinutes(-5)
},
new ActivityLog
{
Type = "warning",
Title = "API 레이트 제한",
Description = "KIS API 레이트 제한에 도달했으나 재시도 예정",
Timestamp = DateTime.Now.AddMinutes(-12)
},
new ActivityLog
{
Type = "success",
Title = "대시보드 업데이트",
Description = "포트폴리오 구성 분석 완료",
Timestamp = DateTime.Now.AddMinutes(-35)
},
new ActivityLog
{
Type = "info",
Title = "스케줄 실행",
Description = "일일 정기 수집 작업 시작",
Timestamp = DateTime.Now.AddHours(-1)
}
});
}
private async Task RefreshData()
{
await OnInitializedAsync();
}
private string GetActivityIcon(string type) => type switch
{
"success" => Icons.Material.Filled.CheckCircle,
"warning" => Icons.Material.Filled.WarningAmber,
"error" => Icons.Material.Filled.Error,
_ => Icons.Material.Filled.Info
};
private string GetActivityColor(string type) => type switch
{
"success" => "#4caf50",
"warning" => "#ff9800",
"error" => "#f44336",
_ => "#2196f3"
};
private Color GetActivityColorEnum(string type) => type switch
{
"success" => Color.Success,
"warning" => Color.Warning,
"error" => Color.Error,
_ => Color.Info
};
private class ActivityLog
{
public string Type { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public DateTime Timestamp { get; set; }
}
}
@@ -1,229 +0,0 @@
@page "/monitoring"
@attribute [Authorize]
@inject HttpClient Http
@inject ISnackbar Snackbar
<PageTitle>QuantEngine - 데이터 수집 모니터링</PageTitle>
<!-- Page Header -->
<div class="mb-6">
<div class="d-flex justify-content-between align-items-center">
<div>
<MudText Typo="Typo.h4" Class="mb-2">데이터 수집 모니터링</MudText>
<MudText Typo="Typo.body1" Class="text-muted">실시간 수집 작업 상태 및 에러 추적</MudText>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small"
OnClick="RefreshAsync" Disabled="_loading">
<MudIcon Icon="@Icons.Material.Filled.Refresh" Size="Size.Small" Class="mr-2" />
새로고침
</MudButton>
</div>
</div>
@if (_loading)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-4" />
}
<!-- Collection Status Cards -->
<MudGrid Spacing="3" Class="mb-6">
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<MudText Typo="Typo.caption" Class="text-muted mb-2">진행 중인 작업</MudText>
<MudText Typo="Typo.h5">@_runningCount</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<MudText Typo="Typo.caption" Class="text-muted mb-2">완료</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-success);">@_completedCount</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<MudText Typo="Typo.caption" Class="text-muted mb-2">실패</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-error);">@_failedCount</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<MudText Typo="Typo.caption" Class="text-muted mb-2">총 스냅샷</MudText>
<MudText Typo="Typo.h5">@_totalSnapshots</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<!-- Tabs -->
<MudTabs Outlined="true" Class="mb-6">
<!-- Recent Runs -->
<MudTabPanel Text="최근 실행">
<div class="py-4">
<MudPaper Class="pa-4" Elevation="1">
@if (_recentRuns.Count == 0 && !_loading)
{
<MudAlert Severity="Severity.Info">최근 실행 기록이 없습니다.</MudAlert>
}
else
{
<MudTable Items="@_recentRuns" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>실행 ID</MudTh>
<MudTh>시작 시간</MudTh>
<MudTh>종료 시간</MudTh>
<MudTh>상태</MudTh>
<MudTh>스냅샷</MudTh>
<MudTh>에러</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Run ID">
<MudText Typo="Typo.body2" Class="font-monospace">@context.RunId</MudText>
</MudTd>
<MudTd DataLabel="Start">
<MudText Typo="Typo.body2">@FormatTime(context.StartedAt)</MudText>
</MudTd>
<MudTd DataLabel="End">
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(context.FinishedAt) ? "-" : FormatTime(context.FinishedAt))</MudText>
</MudTd>
<MudTd DataLabel="Status">
<MudChip T="string" Label="true" Size="Size.Small"
Color="@GetStatusColor(context.Status)"
Variant="Variant.Filled">
@context.Status
</MudChip>
</MudTd>
<MudTd DataLabel="Snapshots">
<MudText Typo="Typo.body2">@(context.TotalSnapshots?.ToString() ?? "-")</MudText>
</MudTd>
<MudTd DataLabel="Errors">
@if (context.TotalErrors > 0)
{
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Error" Variant="Variant.Outlined">
@context.TotalErrors
</MudChip>
}
else
{
<MudText Typo="Typo.body2">-</MudText>
}
</MudTd>
</RowTemplate>
</MudTable>
}
</MudPaper>
</div>
</MudTabPanel>
<!-- Error Logs -->
<MudTabPanel Text="에러 로그">
<div class="py-4">
<MudPaper Class="pa-4" Elevation="1">
@if (_errors.Count == 0 && !_loading)
{
<MudAlert Severity="Severity.Success">에러가 없습니다.</MudAlert>
}
else
{
<MudStack Spacing="2">
@foreach (var error in _errors)
{
<div class="pa-3" style="border-left: 3px solid #f44336; background-color: var(--mud-palette-surface);">
<div class="d-flex justify-content-between align-items-start mb-2">
<MudText Typo="Typo.body2" Class="font-weight-500">[@error.ErrorKind] @error.ErrorMessage</MudText>
<MudText Typo="Typo.caption" Class="text-muted">@FormatTime(error.CreatedAt)</MudText>
</div>
<MudText Typo="Typo.caption" Class="text-muted">Run: @error.RunId</MudText>
@if (!string.IsNullOrEmpty(error.Ticker))
{
<MudText Typo="Typo.caption" Class="text-muted ml-3">Ticker: @error.Ticker</MudText>
}
</div>
}
</MudStack>
}
</MudPaper>
</div>
</MudTabPanel>
</MudTabs>
@code {
private bool _loading = false;
private int _runningCount;
private int _completedCount;
private int _failedCount;
private int _totalSnapshots;
private List<CollectionRunDto> _recentRuns = new();
private List<CollectionErrorDto> _errors = new();
protected override async Task OnInitializedAsync()
{
await RefreshAsync();
}
private async Task RefreshAsync()
{
_loading = true;
StateHasChanged();
try
{
// 최근 실행 목록 로드
var runsResponse = await Http.GetFromJsonAsync<CollectionRunsResponse>("api/collection/runs?limit=20");
if (runsResponse?.Runs is not null)
{
_recentRuns = runsResponse.Runs;
_runningCount = _recentRuns.Count(r => string.Equals(r.Status, "running", StringComparison.OrdinalIgnoreCase));
_completedCount = _recentRuns.Count(r => string.Equals(r.Status, "completed", StringComparison.OrdinalIgnoreCase)
|| string.Equals(r.Status, "PASS", StringComparison.OrdinalIgnoreCase));
_failedCount = _recentRuns.Count(r => string.Equals(r.Status, "failed", StringComparison.OrdinalIgnoreCase)
|| string.Equals(r.Status, "error", StringComparison.OrdinalIgnoreCase));
_totalSnapshots = _recentRuns.Sum(r => r.TotalSnapshots ?? 0);
}
// 대시보드 상태 로드 (전체 오류 목록)
var state = await Http.GetFromJsonAsync<CollectionDashboardStateDto>("api/collection/state");
if (state?.RecentErrors is not null)
{
_errors = state.RecentErrors;
}
}
catch (Exception ex)
{
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private Color GetStatusColor(string status) => status?.ToLowerInvariant() switch
{
"running" => Color.Info,
"completed" => Color.Success,
"pass" => Color.Success,
"failed" => Color.Error,
"error" => Color.Error,
_ => Color.Warning
};
private string FormatTime(string? isoTime)
{
if (string.IsNullOrEmpty(isoTime)) return "-";
return DateTimeOffset.TryParse(isoTime, out var dt)
? dt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss")
: isoTime;
}
// DTOs (shared with ApiClient)
private record CollectionRunsResponse(List<CollectionRunDto> Runs, int Count);
private record CollectionRunDto(
string RunId, string Status, string StartedAt,
string? FinishedAt, int? TotalSnapshots, int? TotalErrors);
private record CollectionDashboardStateDto(
string? LastRunId, string? LastRunStatus, string? LastFinishedAt,
int TotalSnapshots, int TotalErrors, List<CollectionErrorDto> RecentErrors);
private record CollectionErrorDto(
string RunId, string SourceName, string ErrorKind,
string ErrorMessage, string? Ticker, string CreatedAt);
}
@@ -1,8 +0,0 @@
@page "/not-found"
@layout MainLayout
<!-- 🎯 DEBUG MARKER: NOTFOUND_RENDERING -->
<div id="notfound-debug-marker" style="display:none;">NOTFOUND_RENDERING_ACTIVE</div>
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
@@ -1,121 +0,0 @@
@page "/operations"
@attribute [Authorize]
@using QuantEngine.Core.Infrastructure
@inject HttpClient Http
<PageTitle>QuantEngine - Operations</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">Operational Report</MudText>
<MudText Typo="Typo.body2" Class="mb-4">Temp/operational_report.json만 읽는 운영 고정 화면입니다.</MudText>
<MudGrid Spacing="2" Class="mb-4">
<MudItem xs="12" sm="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">Schema</MudText>
<MudText Typo="Typo.h6">@SchemaVersion</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">Sections</MudText>
<MudText Typo="Typo.h6">@SectionCountLabel</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">Source</MudText>
<MudText Typo="Typo.h6">@SourceJson</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">Generated</MudText>
<MudText Typo="Typo.h6">@GeneratedAt</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudGrid Spacing="2" Class="mb-4">
@foreach (var section in HighlightSections)
{
<MudItem xs="12" sm="6" md="3" @key="section.Name">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.caption">@(section.Name)</MudText>
<MudText Typo="Typo.h6">@(section.Title)</MudText>
<MudText Typo="Typo.body2">@(section.Preview)</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Report Health</MudText>
<MudStack Spacing="1">
<MudText Typo="Typo.body2">Status: <MudChip T="string" Color="@(HealthLabel == "PASS" ? Color.Success : Color.Warning)" Variant="Variant.Filled">@HealthLabel</MudChip></MudText>
<MudText Typo="Typo.body2">Path: @ReportPath</MudText>
<MudText Typo="Typo.body2">Sections rendered: @RenderedSectionCountLabel</MudText>
</MudStack>
</MudPaper>
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Sections</MudText>
@if (Sections.Count == 0)
{
<MudAlert Severity="Severity.Warning">DATA_MISSING: operational_report.json에 표시할 섹션이 없습니다.</MudAlert>
}
else
{
<MudTable Items="@Sections" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Title</MudTh>
<MudTh>Preview</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Title">@context.Title</MudTd>
<MudTd DataLabel="Preview">@context.Preview</MudTd>
</RowTemplate>
</MudTable>
}
</MudPaper>
@code {
private readonly List<OperationalReportSection> Sections = new();
private readonly List<OperationalReportSection> HighlightSections = new();
private string SchemaVersion = "n/a";
private string SourceJson = "n/a";
private string GeneratedAt = "n/a";
private string SectionCountLabel = "0";
private string RenderedSectionCountLabel = "0";
private string HealthLabel = "DATA_MISSING";
private string ReportPath = "n/a";
protected override async Task OnInitializedAsync()
{
try
{
var report = await Http.GetFromJsonAsync<OperationalReportData>("api/operational-report");
if (report != null)
{
SchemaVersion = report.SchemaVersion;
SourceJson = report.SourceJson;
GeneratedAt = report.GeneratedAt;
Sections.Clear();
Sections.AddRange(report.Sections);
HighlightSections.Clear();
HighlightSections.AddRange(Sections.Take(4));
SectionCountLabel = report.SectionCount.ToString();
RenderedSectionCountLabel = Sections.Count.ToString();
HealthLabel = Sections.Count > 0 ? "PASS" : "DATA_MISSING";
}
}
catch
{
HealthLabel = "DATA_MISSING";
}
}
}
@@ -1,238 +0,0 @@
@page "/portfolio"
@attribute [Authorize]
@inject HttpClient Http
<PageTitle>QuantEngine - 포트폴리오</PageTitle>
<!-- Page Header -->
<div class="mb-6">
<MudText Typo="Typo.h4" Class="mb-2">포트폴리오</MudText>
<MudText Typo="Typo.body1" Class="text-muted">자산 구성 및 성과 분석</MudText>
</div>
<!-- Summary Cards -->
<MudGrid Spacing="3" Class="mb-6">
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<MudText Typo="Typo.caption" Class="text-muted mb-1">총 평가액</MudText>
<MudText Typo="Typo.h5" Class="text-primary">₩125.5M</MudText>
<MudText Typo="Typo.body2" Class="text-success mt-1">+3.2% (이번 달)</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<MudText Typo="Typo.caption" Class="text-muted mb-1">보유 종목</MudText>
<MudText Typo="Typo.h5">12개</MudText>
<MudText Typo="Typo.body2" Class="text-muted mt-1">주식 및 펀드</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<MudText Typo="Typo.caption" Class="text-muted mb-1">수익률</MudText>
<MudText Typo="Typo.h5" Class="text-success">+8.5%</MudText>
<MudText Typo="Typo.body2" Class="text-muted mt-1">연간 기준</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
<MudText Typo="Typo.caption" Class="text-muted mb-1">위험도</MudText>
<MudText Typo="Typo.h5">중간</MudText>
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled" Class="mt-1">
Moderate
</MudChip>
</MudPaper>
</MudItem>
</MudGrid>
<!-- Asset Breakdown -->
<MudGrid Spacing="3" Class="mb-6">
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-4">자산 구성</MudText>
<MudTable Items="@_assets" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>종목/펀드명</MudTh>
<MudTh>수량</MudTh>
<MudTh>현재가</MudTh>
<MudTh>평가액</MudTh>
<MudTh>수익률</MudTh>
<MudTh>비율</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">
<div class="d-flex align-items-center gap-2">
<MudAvatar Size="Size.Small" Color="Color.Primary">@context.Name[0]</MudAvatar>
<div>
<MudText Typo="Typo.body2" Class="font-weight-500">@context.Name</MudText>
<MudText Typo="Typo.caption" Class="text-muted">@context.Ticker</MudText>
</div>
</div>
</MudTd>
<MudTd DataLabel="Quantity">
<MudText Typo="Typo.body2">@context.Quantity.ToString("N0")</MudText>
</MudTd>
<MudTd DataLabel="Price">
<MudText Typo="Typo.body2">₩@context.CurrentPrice.ToString("N0")</MudText>
</MudTd>
<MudTd DataLabel="Value">
<MudText Typo="Typo.body2" Class="font-weight-500">₩@context.Value.ToString("N0")</MudText>
</MudTd>
<MudTd DataLabel="Return">
<MudChip T="string" Label="true" Size="Size.Small"
Color="@(context.ReturnRate >= 0 ? Color.Success : Color.Error)"
Variant="Variant.Filled">
@(context.ReturnRate >= 0 ? "+" : "")@context.ReturnRate.ToString("F1")%
</MudChip>
</MudTd>
<MudTd DataLabel="Ratio">
<MudText Typo="Typo.body2">@context.Ratio.ToString("F1")%</MudText>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-4">자산 분류</MudText>
<MudStack Spacing="2">
@foreach (var category in AssetCategories)
{
<div>
<div class="d-flex justify-content-between mb-1">
<MudText Typo="Typo.body2">@category.Name</MudText>
<MudText Typo="Typo.body2" Class="font-weight-500">@category.Percentage%</MudText>
</div>
<MudProgressLinear Value="@category.Percentage" Color="@category.Color" />
</div>
}
</MudStack>
</MudPaper>
</MudItem>
</MudGrid>
<!-- Trading History -->
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-4">거래 이력</MudText>
@if (TradingHistory.Count == 0)
{
<MudAlert Severity="Severity.Info">거래 이력이 없습니다.</MudAlert>
}
else
{
<MudTable Items="@TradingHistory" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>일자</MudTh>
<MudTh>종목</MudTh>
<MudTh>구분</MudTh>
<MudTh>수량</MudTh>
<MudTh>단가</MudTh>
<MudTh>금액</MudTh>
<MudTh>수수료</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Date">
<MudText Typo="Typo.body2">@context.Date.ToString("yyyy-MM-dd")</MudText>
</MudTd>
<MudTd DataLabel="Ticker">
<MudText Typo="Typo.body2">@context.Ticker</MudText>
</MudTd>
<MudTd DataLabel="Type">
<MudChip T="string" Label="true" Size="Size.Small"
Color="@(context.Type == "매수" ? Color.Success : Color.Error)"
Variant="Variant.Filled">
@context.Type
</MudChip>
</MudTd>
<MudTd DataLabel="Quantity">
<MudText Typo="Typo.body2">@context.Quantity</MudText>
</MudTd>
<MudTd DataLabel="Price">
<MudText Typo="Typo.body2">₩@context.Price.ToString("N0")</MudText>
</MudTd>
<MudTd DataLabel="Amount">
<MudText Typo="Typo.body2">₩@context.Amount.ToString("N0")</MudText>
</MudTd>
<MudTd DataLabel="Fee">
<MudText Typo="Typo.body2" Class="text-muted">₩@context.Fee.ToString("N0")</MudText>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudPaper>
@code {
private List<AssetModel> _assets = new();
private List<CategoryModel> AssetCategories = new();
private List<TradeModel> TradingHistory = new();
protected override async Task OnInitializedAsync()
{
await LoadAssets();
}
private async Task LoadAssets()
{
_assets = new List<AssetModel>
{
new AssetModel { Name = "삼성전자", Ticker = "005930", Quantity = 50, CurrentPrice = 70000, Value = 3500000, ReturnRate = 5.2M, Ratio = 28.0M },
new AssetModel { Name = "LG화학", Ticker = "051910", Quantity = 30, CurrentPrice = 820000, Value = 24600000, ReturnRate = -2.1M, Ratio = 19.6M },
new AssetModel { Name = "현대차", Ticker = "005380", Quantity = 40, CurrentPrice = 245000, Value = 9800000, ReturnRate = 8.5M, Ratio = 7.8M },
new AssetModel { Name = "SK하이닉스", Ticker = "000660", Quantity = 25, CurrentPrice = 105000, Value = 2625000, ReturnRate = 12.3M, Ratio = 2.1M },
new AssetModel { Name = "삼성중공업", Ticker = "010140", Quantity = 60, CurrentPrice = 85000, Value = 5100000, ReturnRate = 3.7M, Ratio = 4.1M },
new AssetModel { Name = "포스코", Ticker = "005490", Quantity = 20, CurrentPrice = 75000, Value = 1500000, ReturnRate = -5.2M, Ratio = 1.2M },
};
AssetCategories = new List<CategoryModel>
{
new CategoryModel { Name = "대형주", Percentage = 45, Color = Color.Primary },
new CategoryModel { Name = "중형주", Percentage = 30, Color = Color.Secondary },
new CategoryModel { Name = "소형주", Percentage = 15, Color = Color.Info },
new CategoryModel { Name = "채권/현금", Percentage = 10, Color = Color.Success }
};
TradingHistory = new List<TradeModel>
{
new TradeModel { Date = DateTime.Now.AddDays(-5), Ticker = "005930", Type = "매수", Quantity = 10, Price = 68000, Amount = 680000, Fee = 1360 },
new TradeModel { Date = DateTime.Now.AddDays(-10), Ticker = "051910", Type = "매도", Quantity = 5, Price = 850000, Amount = 4250000, Fee = 8500 },
new TradeModel { Date = DateTime.Now.AddDays(-15), Ticker = "005380", Type = "매수", Quantity = 20, Price = 240000, Amount = 4800000, Fee = 9600 },
};
await Task.CompletedTask;
}
private class AssetModel
{
public string Name { get; set; }
public string Ticker { get; set; }
public int Quantity { get; set; }
public decimal CurrentPrice { get; set; }
public decimal Value { get; set; }
public decimal ReturnRate { get; set; }
public decimal Ratio { get; set; }
}
private class CategoryModel
{
public string Name { get; set; }
public int Percentage { get; set; }
public Color Color { get; set; }
}
private class TradeModel
{
public DateTime Date { get; set; }
public string Ticker { get; set; }
public string Type { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
public decimal Amount { get; set; }
public decimal Fee { get; set; }
}
}
@@ -1,268 +0,0 @@
@page "/users"
@attribute [Authorize]
@using MudBlazor
@inject HttpClient Http
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>QuantEngine - 사용자 관리</PageTitle>
<!-- Page Header -->
<div class="mb-6">
<MudText Typo="Typo.h4" Class="mb-2">사용자 관리</MudText>
<MudText Typo="Typo.body1" Class="text-muted">시스템 사용자 및 권한 관리</MudText>
</div>
<!-- Action Bar -->
<div class="d-flex justify-content-between align-items-center mb-4">
<MudTextField @bind-Value="SearchQuery" Placeholder="사용자 검색..."
StartAdornment="@Icons.Material.Filled.Search"
Style="width: 300px;" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenAddUserDialog">
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-2" />
새 사용자 추가
</MudButton>
</div>
<!-- Users Table -->
<MudPaper Class="pa-4" Elevation="1">
@if (_users.Count == 0)
{
<MudAlert Severity="Severity.Info">사용자가 없습니다.</MudAlert>
}
else
{
<MudTable Items="@FilteredUsers" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>이름</MudTh>
<MudTh>역할</MudTh>
<MudTh>상태</MudTh>
<MudTh>생성일</MudTh>
<MudTh>수정일</MudTh>
<MudTh>작업</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">
<div class="d-flex align-items-center gap-2">
<MudAvatar Size="Size.Small" Color="Color.Primary">@context.Username[0].ToString().ToUpper()</MudAvatar>
<MudText Typo="Typo.body2">@context.Username</MudText>
</div>
</MudTd>
<MudTd DataLabel="Role">
<MudChip T="string" Label="true" Size="Size.Small"
Color="@(context.Role == "Admin" ? Color.Primary : (context.Role == "Operator" ? Color.Secondary : Color.Default))"
Variant="Variant.Filled">
@context.Role
</MudChip>
</MudTd>
<MudTd DataLabel="Status">
<MudChip T="string" Label="true" Size="Size.Small"
Color="@(context.IsActive ? Color.Success : Color.Warning)"
Variant="Variant.Filled">
@(context.IsActive ? "활성" : "비활성")
</MudChip>
</MudTd>
<MudTd DataLabel="Joined">
<MudText Typo="Typo.body2">@FormatDate(context.CreatedAt)</MudText>
</MudTd>
<MudTd DataLabel="Updated">
<MudText Typo="Typo.body2">@FormatDate(context.UpdatedAt)</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" OnClick="@(() => EditUser(context))">편집</MudButton>
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteUser(context))">삭제</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudPaper>
<!-- Add/Edit Dialog -->
<MudDialog @bind-Visible="_dialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@(_isEditMode ? Icons.Material.Filled.Edit : Icons.Material.Filled.Add)" Class="mr-3" />
@(_isEditMode ? "사용자 편집" : "새 사용자 추가")
</MudText>
</TitleContent>
<DialogContent>
<MudForm Model="@_formModel" @ref="_form">
<MudTextField T="string" @bind-Value="_formModel.Username" Label="사용자 ID" Required="true" Disabled="@_isEditMode"
RequiredError="사용자 ID를 입력해 주세요." Class="mb-3" />
<MudTextField T="string" @bind-Value="_formModel.Password" Label="@(_isEditMode ? "새 비밀번호 (미입력시 유지)" : "비밀번호")"
InputType="InputType.Password" Required="@(!_isEditMode)" RequiredError="비밀번호를 입력해 주세요." Class="mb-3" />
<MudSelect T="string" @bind-Value="_formModel.Role" Label="역할 권한" Required="true" Class="mb-3">
<MudSelectItem Value="@("Admin")">Admin (관리자)</MudSelectItem>
<MudSelectItem Value="@("Operator")">Operator (운영자)</MudSelectItem>
<MudSelectItem Value="@("Viewer")">Viewer (조회자)</MudSelectItem>
</MudSelect>
@if (_isEditMode)
{
<MudSwitch T="bool" @bind-Value="_formModel.IsActive" Color="Color.Success" Label="계정 활성화 상태" />
}
</MudForm>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CloseDialog" Class="px-5">취소</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveUser" Class="px-5">저장</MudButton>
</DialogActions>
</MudDialog>
@code {
private List<UserDto> _users = new();
private string SearchQuery = "";
private bool _dialogVisible;
private bool _isEditMode;
private MudForm _form = new();
private UserFormModel _formModel = new();
private DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true };
private IEnumerable<UserDto> FilteredUsers
{
get => string.IsNullOrEmpty(SearchQuery)
? _users
: _users.Where(u => u.Username.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase));
}
protected override async Task OnInitializedAsync()
{
await LoadUsers();
}
private async Task LoadUsers()
{
try
{
// BaseAddress is set to HostEnvironment.BaseAddress by DI in Client/Program.cs.
// Never override it with a hardcoded port.
var res = await Http.GetFromJsonAsync<List<UserDto>>("api/users");
if (res != null)
{
_users = res;
}
}
catch (Exception ex)
{
Snackbar.Add($"사용자 목록 로드 실패: {ex.Message}", Severity.Error);
}
}
private void OpenAddUserDialog()
{
_isEditMode = false;
_formModel = new UserFormModel { Role = "Viewer", IsActive = true };
_dialogVisible = true;
}
private void EditUser(UserDto user)
{
_isEditMode = true;
_formModel = new UserFormModel
{
Username = user.Username,
Role = user.Role,
IsActive = user.IsActive,
Password = "" // Clear password field for security
};
_dialogVisible = true;
}
private async Task DeleteUser(UserDto user)
{
bool? result = await DialogService.ShowMessageBoxAsync(
"사용자 삭제",
$"정말로 사용자 '{user.Username}' 계정을 비활성화하시겠습니까?",
yesText: "비활성화", cancelText: "취소");
if (result == true)
{
try
{
var response = await Http.DeleteAsync($"api/users?username={user.Username}");
if (response.IsSuccessStatusCode)
{
Snackbar.Add("사용자 계정이 비활성화되었습니다.", Severity.Success);
await LoadUsers();
}
else
{
Snackbar.Add("계정 비활성화 작업에 실패했습니다.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"API 에러: {ex.Message}", Severity.Error);
}
}
}
private void CloseDialog()
{
_dialogVisible = false;
}
private async Task SaveUser()
{
await _form.Validate();
if (!_form.IsValid) return;
try
{
HttpResponseMessage response;
if (_isEditMode)
{
response = await Http.PutAsJsonAsync("api/users", _formModel);
}
else
{
response = await Http.PostAsJsonAsync("api/users", _formModel);
}
if (response.IsSuccessStatusCode)
{
Snackbar.Add("사용자 정보가 성공적으로 저장되었습니다.", Severity.Success);
_dialogVisible = false;
await LoadUsers();
}
else
{
var error = await response.Content.ReadAsStringAsync();
Snackbar.Add($"저장 실패: {error}", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"API 오류 발생: {ex.Message}", Severity.Error);
}
}
private string FormatDate(string isoString)
{
if (string.IsNullOrWhiteSpace(isoString)) return "-";
if (DateTime.TryParse(isoString, out var dt))
{
return dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
}
return isoString;
}
public class UserDto
{
public string Username { get; set; } = string.Empty;
public string Role { get; set; } = string.Empty;
public bool IsActive { get; set; }
public string CreatedAt { get; set; } = string.Empty;
public string UpdatedAt { get; set; } = string.Empty;
}
public class UserFormModel
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string Role { get; set; } = "Viewer";
public bool IsActive { get; set; } = true;
}
}
@@ -1,27 +0,0 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Components.Authorization;
using QuantEngine.Web.Client.Services;
using QuantEngine.Web.Client.Infrastructure;
using MudBlazor.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Register LocalStorage for cross-platform session persistence
builder.Services.AddScoped<LocalStorageService>();
// App State Service (RBAC & global state management)
builder.Services.AddScoped<AppStateService>();
// Authentication setup in WebAssembly client
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
// MudBlazor Services (CRITICAL: Required for Interactive WebAssembly)
builder.Services.AddMudServices();
// HttpClient register (API-First standard)
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<ApiClient>();
await builder.Build().RunAsync();
@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\QuantEngine.Core\QuantEngine.Core.csproj" />
<ProjectReference Include="..\..\QuantEngine.Application\QuantEngine.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.0" />
<PackageReference Include="MudBlazor" Version="9.0.0" />
</ItemGroup>
</Project>
@@ -1,8 +0,0 @@
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
NavigationManager.NavigateTo("login");
}
}
@@ -1,221 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Web.Client.Services;
public class ApiClient
{
private readonly HttpClient _http;
private readonly ILogger<ApiClient> _logger;
public ApiClient(HttpClient http, ILogger<ApiClient> logger)
{
_http = http;
// BaseAddress is set by the DI registration in Client/Program.cs via
// builder.HostEnvironment.BaseAddress — never hardcode a port here.
if (_http.BaseAddress == null)
{
throw new InvalidOperationException(
"ApiClient: HttpClient.BaseAddress is null. " +
"Ensure the HttpClient is registered with HostEnvironment.BaseAddress in Client/Program.cs.");
}
_logger = logger;
}
// Collection API Methods
public async Task<CollectionDashboardStateDto?> GetCollectionStateAsync()
{
try
{
return await _http.GetFromJsonAsync<CollectionDashboardStateDto>("api/collection/state");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching collection state");
return null;
}
}
public async Task<CollectionRunsResponse?> GetCollectionRunsAsync(int limit = 20)
{
try
{
return await _http.GetFromJsonAsync<CollectionRunsResponse>($"api/collection/runs?limit={limit}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching collection runs");
return null;
}
}
public async Task<CollectionRunSnapshotsResponse?> GetCollectionSnapshotsAsync(string runId)
{
try
{
return await _http.GetFromJsonAsync<CollectionRunSnapshotsResponse>($"api/collection/runs/{runId}/snapshots");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error fetching snapshots for run {runId}");
return null;
}
}
public async Task<CollectionRunErrorsResponse?> GetCollectionErrorsAsync(string runId, int limit = 50)
{
try
{
return await _http.GetFromJsonAsync<CollectionRunErrorsResponse>($"api/collection/runs/{runId}/errors?limit={limit}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error fetching errors for run {runId}");
return null;
}
}
public async Task<CollectionRunStartResponse?> StartCollectionRunAsync()
{
try
{
var response = await _http.PostAsJsonAsync("api/collection/run", new { });
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<CollectionRunStartResponse>();
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting collection run");
return null;
}
}
}
// DTOs
public class CollectionDashboardStateDto
{
[JsonPropertyName("lastRunId")]
public string? LastRunId { get; set; }
[JsonPropertyName("lastRunStatus")]
public string? LastRunStatus { get; set; }
[JsonPropertyName("lastFinishedAt")]
public string? LastFinishedAt { get; set; }
[JsonPropertyName("totalSnapshots")]
public int TotalSnapshots { get; set; }
[JsonPropertyName("totalErrors")]
public int TotalErrors { get; set; }
[JsonPropertyName("recentErrors")]
public List<CollectionErrorDto> RecentErrors { get; set; } = new();
}
public class CollectionRunDto
{
[JsonPropertyName("runId")]
public string RunId { get; set; } = "";
[JsonPropertyName("status")]
public string Status { get; set; } = "";
[JsonPropertyName("startedAt")]
public string StartedAt { get; set; } = "";
[JsonPropertyName("finishedAt")]
public string? FinishedAt { get; set; }
[JsonPropertyName("totalSnapshots")]
public int? TotalSnapshots { get; set; }
[JsonPropertyName("totalErrors")]
public int? TotalErrors { get; set; }
}
public class CollectionSnapshotDto
{
[JsonPropertyName("runId")]
public string RunId { get; set; } = "";
[JsonPropertyName("datasetName")]
public string DatasetName { get; set; } = "";
[JsonPropertyName("ticker")]
public string Ticker { get; set; } = "";
[JsonPropertyName("sourceName")]
public string SourceName { get; set; } = "";
[JsonPropertyName("capturedAt")]
public string CapturedAt { get; set; } = "";
}
public class CollectionErrorDto
{
[JsonPropertyName("runId")]
public string RunId { get; set; } = "";
[JsonPropertyName("sourceName")]
public string SourceName { get; set; } = "";
[JsonPropertyName("errorKind")]
public string ErrorKind { get; set; } = "";
[JsonPropertyName("errorMessage")]
public string ErrorMessage { get; set; } = "";
[JsonPropertyName("ticker")]
public string Ticker { get; set; } = "";
}
public class CollectionRunsResponse
{
[JsonPropertyName("runs")]
public List<CollectionRunDto> Runs { get; set; } = new();
[JsonPropertyName("count")]
public int Count { get; set; }
}
public class CollectionRunSnapshotsResponse
{
[JsonPropertyName("runId")]
public string RunId { get; set; } = "";
[JsonPropertyName("snapshots")]
public List<CollectionSnapshotDto> Snapshots { get; set; } = new();
[JsonPropertyName("count")]
public int Count { get; set; }
}
public class CollectionRunErrorsResponse
{
[JsonPropertyName("runId")]
public string RunId { get; set; } = "";
[JsonPropertyName("errors")]
public List<CollectionErrorDto> Errors { get; set; } = new();
[JsonPropertyName("count")]
public int Count { get; set; }
}
public class CollectionRunStartResponse
{
[JsonPropertyName("runId")]
public string RunId { get; set; } = "";
[JsonPropertyName("status")]
public string Status { get; set; } = "";
[JsonPropertyName("startedAt")]
public string StartedAt { get; set; } = "";
}
@@ -1,142 +0,0 @@
namespace QuantEngine.Web.Client.Services;
public class AppStateService
{
private UserContext _currentUser;
private List<string> _userRoles = new();
private bool _isInitialized = false;
public event Action OnStateChanged;
public UserContext CurrentUser
{
get => _currentUser;
set
{
_currentUser = value;
NotifyStateChanged();
}
}
public List<string> UserRoles
{
get => _userRoles;
set
{
_userRoles = value;
NotifyStateChanged();
}
}
public bool IsInitialized
{
get => _isInitialized;
set
{
_isInitialized = value;
NotifyStateChanged();
}
}
public AppStateService()
{
_currentUser = new UserContext();
_userRoles = new List<string>();
}
/// <summary>
/// Initialize app state from current user context
/// </summary>
public async Task InitializeAsync(HttpClient httpClient)
{
try
{
var response = await httpClient.GetAsync("api/auth/user");
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
// Parse user info (implement as needed)
CurrentUser = new UserContext { Name = "Admin", Email = "admin@quantengine.local" };
UserRoles = new List<string> { "Admin" };
}
}
catch
{
// Handle error
}
finally
{
IsInitialized = true;
}
}
/// <summary>
/// Check if user has specific role (RBAC)
/// </summary>
public bool HasRole(string role)
{
return UserRoles.Contains(role);
}
/// <summary>
/// Check if user has any of the specified roles
/// </summary>
public bool HasAnyRole(params string[] roles)
{
return roles.Any(r => UserRoles.Contains(r));
}
/// <summary>
/// Check if user has all specified roles
/// </summary>
public bool HasAllRoles(params string[] roles)
{
return roles.All(r => UserRoles.Contains(r));
}
/// <summary>
/// Clear user state
/// </summary>
public void Clear()
{
CurrentUser = new UserContext();
UserRoles = new List<string>();
IsInitialized = false;
}
private void NotifyStateChanged() => OnStateChanged?.Invoke();
}
/// <summary>
/// User context model
/// </summary>
public class UserContext
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.Now;
public bool IsActive { get; set; } = true;
}
/// <summary>
/// API Response wrapper
/// </summary>
public class ApiResponse<T>
{
public bool Success { get; set; }
public string Message { get; set; }
public T Data { get; set; }
}
/// <summary>
/// Pagination model
/// </summary>
public class PaginatedResponse<T>
{
public List<T> Items { get; set; }
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages => (TotalCount + PageSize - 1) / PageSize;
}
@@ -1,43 +0,0 @@
using Microsoft.JSInterop;
using System.Text.Json;
namespace QuantEngine.Web.Client.Services
{
public class LocalStorageService
{
private readonly IJSRuntime _js;
public LocalStorageService(IJSRuntime js)
{
_js = js;
}
public async Task SetAsync<T>(string key, T value)
{
var json = JsonSerializer.Serialize(value);
await _js.InvokeVoidAsync("localStorage.setItem", key, json);
}
public async Task<T?> GetAsync<T>(string key)
{
try
{
var json = await _js.InvokeAsync<string?>("localStorage.getItem", key);
if (string.IsNullOrEmpty(json))
{
return default;
}
return JsonSerializer.Deserialize<T>(json);
}
catch
{
return default;
}
}
public async Task DeleteAsync(string key)
{
await _js.InvokeVoidAsync("localStorage.removeItem", key);
}
}
}
@@ -1,158 +0,0 @@
using MudBlazor;
namespace QuantEngine.Web.Client.Theme;
public static class AppTheme
{
public static MudTheme LightTheme => new()
{
PaletteLight = new PaletteLight
{
Primary = "#3f51b5",
Secondary = "#f50057",
Success = "#4caf50",
Warning = "#ff9800",
Error = "#f44336",
Info = "#2196f3",
Dark = "#121212",
Background = "#fafafa",
Surface = "#ffffff",
TextPrimary = "#212121",
TextSecondary = "rgba(0,0,0,0.6)",
DrawerBackground = "#ffffff",
DrawerText = "#212121",
AppbarBackground = "#3f51b5",
AppbarText = "#ffffff",
ActionDefault = "#c0c0c0",
ActionDisabled = "#f5f5f5",
ActionDisabledBackground = "rgba(0,0,0,0.12)",
Divider = "#e0e0e0",
DividerLight = "#f5f5f5",
TableLines = "#e0e0e0",
LinesDefault = "#e0e0e0",
LinesInputs = "#bdbdbd",
TextDisabled = "rgba(0,0,0,0.38)"
},
Typography = new Typography
{
Default = new DefaultTypography
{
FontFamily = new[] { "Roboto", "sans-serif" },
FontSize = "1rem",
FontWeight = "400",
LineHeight = "1.5",
LetterSpacing = "0.5px"
},
H1 = new H1Typography
{
FontSize = "6rem",
FontWeight = "300",
LineHeight = "1.167",
LetterSpacing = "-0.015625em"
},
H2 = new H2Typography
{
FontSize = "3.75rem",
FontWeight = "300",
LineHeight = "1.2",
LetterSpacing = "-0.0083333333em"
},
H3 = new H3Typography
{
FontSize = "3rem",
FontWeight = "400",
LineHeight = "1.167",
LetterSpacing = "0em"
},
H4 = new H4Typography
{
FontSize = "2.125rem",
FontWeight = "500",
LineHeight = "1.235",
LetterSpacing = "0.0125em"
},
H5 = new H5Typography
{
FontSize = "1.5rem",
FontWeight = "500",
LineHeight = "1.334",
LetterSpacing = "0em"
},
H6 = new H6Typography
{
FontSize = "1.25rem",
FontWeight = "600",
LineHeight = "1.6",
LetterSpacing = "0.0125em"
},
Body1 = new Body1Typography
{
FontSize = "1rem",
FontWeight = "500",
LineHeight = "1.5",
LetterSpacing = "0.03125em"
},
Body2 = new Body2Typography
{
FontSize = "0.875rem",
FontWeight = "400",
LineHeight = "1.43",
LetterSpacing = "0.0178571429em"
},
Button = new ButtonTypography
{
FontSize = "0.875rem",
FontWeight = "600",
LineHeight = "1.75",
LetterSpacing = "0.0892857143em"
},
Caption = new CaptionTypography
{
FontSize = "0.75rem",
FontWeight = "400",
LineHeight = "1.66",
LetterSpacing = "0.0333333333em"
}
},
LayoutProperties = new LayoutProperties
{
DefaultBorderRadius = "4px",
DrawerWidthLeft = "256px",
DrawerWidthRight = "256px",
AppbarHeight = "64px",
}
};
public static MudTheme DarkTheme => new()
{
PaletteDark = new PaletteDark
{
Primary = "#bb86fc",
Secondary = "#03dac6",
Success = "#4caf50",
Warning = "#ff9800",
Error = "#cf6679",
Info = "#2196f3",
Dark = "#121212",
Background = "#121212",
Surface = "#1e1e1e",
TextPrimary = "#ffffff",
TextSecondary = "rgba(255,255,255,0.7)",
DrawerBackground = "#1e1e1e",
DrawerText = "#ffffff",
AppbarBackground = "#1f1f1f",
AppbarText = "#ffffff",
ActionDefault = "#3f3f3f",
ActionDisabled = "#1e1e1e",
ActionDisabledBackground = "rgba(255,255,255,0.12)",
Divider = "#37474f",
DividerLight = "#2c3e50",
TableLines = "#37474f",
LinesDefault = "#37474f",
LinesInputs = "#555555",
TextDisabled = "rgba(255,255,255,0.38)"
},
Typography = LightTheme.Typography,
LayoutProperties = LightTheme.LayoutProperties
};
}
@@ -1,16 +0,0 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MudBlazor
@using QuantEngine.Web.Client
@using QuantEngine.Web.Client.Pages
@using QuantEngine.Web.Client.Layout
@using QuantEngine.Web.Client.Infrastructure
@using QuantEngine.Web.Client.Services
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization
-297
View File
@@ -1,297 +0,0 @@
/* QuantEngine Global Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
font-family: 'Roboto', sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.5;
color: var(--mud-palette-text-primary, #212121);
background-color: var(--mud-palette-background, #fafafa);
transition: background-color 0.3s ease, color 0.3s ease;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--mud-palette-surface, #ffffff);
}
::-webkit-scrollbar-thumb {
background: var(--mud-palette-action-default, #c0c0c0);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--mud-palette-primary, #3f51b5);
}
/* Text Utilities */
.text-primary {
color: var(--mud-palette-primary, #3f51b5);
}
.text-secondary {
color: var(--mud-palette-secondary, #f50057);
}
.text-success {
color: var(--mud-palette-success, #4caf50);
}
.text-warning {
color: var(--mud-palette-warning, #ff9800);
}
.text-error {
color: var(--mud-palette-error, #f44336);
}
.text-muted {
color: var(--mud-palette-text-secondary, rgba(0,0,0,0.6));
}
/* Spacing Utilities */
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 1rem; }
.mt-4 { margin-top: 1.5rem; }
.mt-5 { margin-top: 3rem; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 1rem; }
.mb-4 { margin-bottom: 1.5rem; }
.mb-5 { margin-bottom: 3rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
.my-auto { margin-top: auto; margin-bottom: auto; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
/* Flex Utilities */
.d-flex {
display: flex;
}
.flex-column {
flex-direction: column;
}
.align-items-center {
align-items: center;
}
.justify-content-center {
justify-content: center;
}
.justify-content-between {
justify-content: space-between;
}
/* Gap Utilities */
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 1rem; }
.gap-4 { gap: 1.5rem; }
/* Loading Skeleton */
.skeleton {
background: linear-gradient(
90deg,
var(--mud-palette-surface, #fff) 0%,
var(--mud-palette-divider, #e0e0e0) 50%,
var(--mud-palette-surface, #fff) 100%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* MudBlazor Overrides */
.mud-appbar {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.mud-drawer {
border-right: 1px solid var(--mud-palette-divider, #e0e0e0);
}
.mud-drawer-content {
padding: 1rem;
}
.mud-nav-link {
border-radius: 4px;
margin-bottom: 0.25rem;
transition: all 0.2s ease;
}
.mud-nav-link:hover {
background-color: var(--mud-palette-action-default-hover, rgba(0, 0, 0, 0.04));
}
.mud-nav-link.mud-ripple-nav-link-active {
background-color: var(--mud-palette-primary-lighten, rgba(63, 81, 181, 0.1));
color: var(--mud-palette-primary, #3f51b5);
font-weight: 600;
}
.mud-card {
border: 1px solid var(--mud-palette-divider, #e0e0e0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.mud-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.mud-button {
text-transform: none;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 4px;
}
.mud-button-root:disabled {
opacity: 0.6;
}
/* Forms */
.mud-input-control {
margin-bottom: 1rem;
}
.mud-input-label {
font-weight: 500;
}
.mud-input {
border-radius: 4px;
}
.mud-input.mud-input-text {
background-color: var(--mud-palette-surface, #ffffff);
}
/* Tables */
.mud-table {
background-color: var(--mud-palette-surface, #ffffff);
}
.mud-table-head {
background-color: var(--mud-palette-background, #fafafa);
}
.mud-table-row:hover {
background-color: var(--mud-palette-action-default-hover, rgba(0, 0, 0, 0.04));
}
.mud-table-cell {
padding: 1rem;
border-color: var(--mud-palette-divider, #e0e0e0);
}
/* Responsive */
@media (max-width: 600px) {
body {
font-size: 13px;
}
.mud-drawer {
width: 100% !important;
max-width: 90% !important;
}
.mud-appbar {
height: 56px;
}
.mud-table-cell {
padding: 0.75rem 0.5rem;
}
}
/* Animation Classes */
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.slide-in {
animation: slideIn 0.3s ease-in;
}
@keyframes slideIn {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Print Styles */
@media print {
.mud-appbar,
.mud-drawer,
.no-print {
display: none !important;
}
body {
background: white;
}
}
@@ -1,4 +1,6 @@
using FastEndpoints; using FastEndpoints;
using Hangfire;
using QuantEngine.Application.Interfaces;
using QuantEngine.Core.Interfaces; using QuantEngine.Core.Interfaces;
namespace QuantEngine.Web.Endpoints; namespace QuantEngine.Web.Endpoints;
@@ -214,20 +216,52 @@ public class GetLatestSnapshotsEndpoint : Endpoint<GetLatestSnapshotsRequest, Ge
} }
} }
public class StartCollectionRunEndpoint : EndpointWithoutRequest public class StartCollectionRunResponse
{ {
public string RunId { get; set; } = "";
}
public class StartCollectionRunEndpoint : EndpointWithoutRequest<StartCollectionRunResponse>
{
private readonly IBackgroundJobClient _jobClient;
private readonly IConfiguration _configuration;
private readonly ILogger<StartCollectionRunEndpoint> _logger;
public StartCollectionRunEndpoint(
IBackgroundJobClient jobClient,
IConfiguration configuration,
ILogger<StartCollectionRunEndpoint> logger)
{
_jobClient = jobClient;
_configuration = configuration;
_logger = logger;
}
public override void Configure() public override void Configure()
{ {
Post("/api/collection/run"); Post("/api/collection/run");
AllowAnonymous();
Description(d => d Description(d => d
.Produces(202) .Produces<StartCollectionRunResponse>(202)
.Produces(500)); .Produces(500));
} }
public override async Task HandleAsync(CancellationToken ct) public override async Task HandleAsync(CancellationToken ct)
{ {
// Return 202 Accepted status code via generic status code handler try
await SendResultAsync(Microsoft.AspNetCore.Http.Results.Accepted()); {
var runId = $"api-{DateTime.Now:yyyyMMdd-HHmmss}";
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }.ToList();
_jobClient.Enqueue<ICollectionOrchestrator>(o => o.RunCollectionAsync(runId, accountMode, tickers));
_logger.LogInformation("Collection run {RunId} enqueued", runId);
await SendAsync(new StartCollectionRunResponse { RunId = runId }, 202, ct);
}
catch
{
await SendErrorsAsync(500, ct);
}
} }
} }
@@ -266,6 +266,7 @@
<div class="login-footer"> <div class="login-footer">
<p>© 2026 QuantEngine. 모든 권리 예약.</p> <p>© 2026 QuantEngine. 모든 권리 예약.</p>
<p style="font-size: 11px; margin-top: 4px; opacity: 0.8;">Version: @Model.AppVersion</p>
</div> </div>
</div> </div>
</body> </body>
@@ -1,3 +1,4 @@
using System.Reflection;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -17,6 +18,64 @@ public class LoginModel : PageModel
public string? Username { get; set; } public string? Username { get; set; }
public bool RememberUsername { get; set; } public bool RememberUsername { get; set; }
public string? ErrorMessage { get; set; } public string? ErrorMessage { get; set; }
public string AppVersion
{
get
{
var rawVersion = Assembly.GetEntryAssembly()
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (!string.IsNullOrEmpty(rawVersion) && rawVersion.StartsWith("quant_", StringComparison.OrdinalIgnoreCase))
{
return rawVersion;
}
var dateStr = DateTime.UtcNow.AddHours(9).ToString("yyyyMMdd");
string gitHash = "a9986fb";
try
{
var baseDir = AppContext.BaseDirectory;
var current = new DirectoryInfo(baseDir);
string? repoRoot = null;
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
repoRoot = current.FullName;
break;
}
current = current.Parent;
}
if (repoRoot != null)
{
var headPath = Path.Combine(repoRoot, ".git", "HEAD");
if (System.IO.File.Exists(headPath))
{
var headContent = System.IO.File.ReadAllText(headPath).Trim();
if (headContent.StartsWith("ref:"))
{
var refPath = Path.Combine(repoRoot, ".git", headContent.Substring(4).Trim());
if (System.IO.File.Exists(refPath))
{
var fullHash = System.IO.File.ReadAllText(refPath).Trim();
if (fullHash.Length >= 7)
gitHash = fullHash.Substring(0, 7);
}
}
else if (headContent.Length >= 7)
{
gitHash = headContent.Substring(0, 7);
}
}
}
}
catch {}
int runCount = 14;
return $"quant_{dateStr}.{runCount}.{gitHash}";
}
}
public LoginModel(AuthService authService, IIpLockoutService lockoutService, ILogger<LoginModel> logger) public LoginModel(AuthService authService, IIpLockoutService lockoutService, ILogger<LoginModel> logger)
{ {
@@ -1,4 +1,4 @@
@page "{runId}" @page "{runId?}"
@model QuantEngine.Web.Pages.Admin.Collection.DetailModel @model QuantEngine.Web.Pages.Admin.Collection.DetailModel
@{ @{
ViewData["Title"] = "수집 실행 상세"; ViewData["Title"] = "수집 실행 상세";
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Core.Interfaces; using QuantEngine.Core.Interfaces;
using QuantEngine.Web.Services; using QuantEngine.Web.Services;
@@ -19,16 +20,31 @@ public class DetailModel : PageModel
_logger = logger; _logger = logger;
} }
public async Task OnGetAsync(string runId) public async Task OnGetAsync([FromQuery] string? runId, [FromRoute] string? routeRunId)
{ {
var targetRunId = runId ?? routeRunId;
if (string.IsNullOrEmpty(targetRunId))
{
if (RouteData.Values.TryGetValue("runId", out var routeVal))
{
targetRunId = routeVal?.ToString();
}
}
if (string.IsNullOrEmpty(targetRunId))
{
_logger.LogWarning("Detail page loaded without a runId");
return;
}
try try
{ {
var runs = await _collectionRepository.GetRecentRunsAsync(limit: 100); var runs = await _collectionRepository.GetRecentRunsAsync(limit: 100);
Run = runs.FirstOrDefault(r => r.RunId == runId); Run = runs.FirstOrDefault(r => r.RunId == targetRunId);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to load collection run detail"); _logger.LogError(ex, "Failed to load collection run detail for runId: {RunId}", targetRunId);
} }
} }
} }
@@ -0,0 +1,30 @@
@page
@model QuantEngine.Web.Pages.Admin.Collection.StartModel
@{
ViewData["Title"] = "수집 시작";
}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">데이터 수집 시작</h2>
<div class="text-muted">Hangfire 백그라운드 작업으로 수집을 시작합니다.</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<div class="alert alert-danger">@Model.ErrorMessage</div>
<a href="/Admin/Collection" class="btn btn-secondary">목록으로</a>
}
else
{
<div class="alert alert-success">수집 작업이 등록되었습니다. 실행 ID: <code>@Model.RunId</code></div>
<a href="/Admin/Collection/Detail?runId=@Uri.EscapeDataString(Model.RunId!)" class="btn btn-primary">실행 상세 보기</a>
<a href="/Admin/Collection" class="btn btn-secondary">목록으로</a>
}
</div>
</div>
@@ -0,0 +1,48 @@
using Hangfire;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Application.Interfaces;
using QuantEngine.Web.Services;
namespace QuantEngine.Web.Pages.Admin.Collection;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class StartModel : PageModel
{
private readonly IBackgroundJobClient _jobClient;
private readonly IConfiguration _configuration;
private readonly ILogger<StartModel> _logger;
public string? RunId { get; private set; }
public string? ErrorMessage { get; private set; }
public StartModel(
IBackgroundJobClient jobClient,
IConfiguration configuration,
ILogger<StartModel> logger)
{
_jobClient = jobClient;
_configuration = configuration;
_logger = logger;
}
public void OnGet()
{
try
{
RunId = $"admin-{DateTime.Now:yyyyMMdd-HHmmss}";
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }.ToList();
_jobClient.Enqueue<ICollectionOrchestrator>(orchestrator =>
orchestrator.RunCollectionAsync(RunId!, accountMode, tickers));
_logger.LogInformation("Collection run {RunId} enqueued from admin page", RunId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to enqueue collection run from admin page");
ErrorMessage = "수집 작업을 등록하지 못했습니다. Hangfire 상태와 서버 로그를 확인하세요.";
}
}
}
@@ -26,71 +26,28 @@
<small class="text-muted">데이터 수집 실행</small> <small class="text-muted">데이터 수집 실행</small>
</div> </div>
</div> </div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-card-label">시스템 상태</div>
<div class="stat-card-number">
<span class="status-dot active"></span>정상
</div>
<small class="text-muted">모든 서비스 운영 중</small>
</div>
</div>
<div class="col-md-3"> <div class="col-md-3">
<div class="stat-card"> <div class="stat-card">
<div class="stat-card-label">데이터베이스</div> <div class="stat-card-label">데이터베이스</div>
<div class="stat-card-number"> <div class="stat-card-number">
<span class="status-dot active"></span>연결됨 @if (Model.IsDatabaseConnected)
{
<span class="status-dot active"></span><text>연결됨</text>
}
else
{
<span class="status-dot" style="background-color:#e74c3c;"></span><text>연결 끊김</text>
}
</div> </div>
<small class="text-muted">PostgreSQL 정상</small> <small class="text-muted">@(Model.IsDatabaseConnected ? "PostgreSQL 정상" : "데이터 조회 실패 - 로그 확인 필요")</small>
</div> </div>
</div> </div>
</div> </div>
<!-- Main Content Row --> <!-- Main Content Row -->
<div class="row"> <div class="row">
<!-- System Events Card -->
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">최근 시스템 이벤트</h3>
</div>
<div class="table-responsive">
<table class="table card-table table-vcenter">
<thead>
<tr>
<th>시간</th>
<th>이벤트</th>
<th>유형</th>
<th>상태</th>
</tr>
</thead>
<tbody>
<tr>
<td>@DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td>시스템 초기화</td>
<td><span class="badge badge-info">INFO</span></td>
<td><span class="badge badge-success">완료</span></td>
</tr>
<tr>
<td>@DateTime.Now.AddHours(-1).ToString("yyyy-MM-dd HH:mm:ss")</td>
<td>데이터 수집 완료</td>
<td><span class="badge badge-info">INFO</span></td>
<td><span class="badge badge-success">완료</span></td>
</tr>
<tr>
<td>@DateTime.Now.AddHours(-2).ToString("yyyy-MM-dd HH:mm:ss")</td>
<td>데이터베이스 백업</td>
<td><span class="badge badge-secondary">SYSTEM</span></td>
<td><span class="badge badge-success">완료</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Quick Actions Card --> <!-- Quick Actions Card -->
<div class="col-lg-4"> <div class="col-lg-6">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">빠른 작업</h3> <h3 class="card-title">빠른 작업</h3>
@@ -117,24 +74,26 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- System Info Card --> <!-- System Info Card -->
<div class="card mt-3"> <div class="col-lg-6">
<div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">시스템 정보</h3> <h3 class="card-title">시스템 정보</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-2"> <div class="mb-2">
<span class="text-muted">버전:</span> <span class="text-muted">환경:</span>
<strong>v0.1.0</strong> <strong>@Model.EnvironmentName</strong>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<span class="text-muted">환경:</span> <span class="text-muted">데이터베이스:</span>
<strong>Production</strong> <strong>@(Model.IsDatabaseConnected ? "연결됨" : "연결 끊김")</strong>
</div> </div>
<div> <div>
<span class="text-muted">업타임:</span> <span class="text-muted">배포 버전:</span>
<strong>정상</strong> <strong>@Model.AppVersion</strong>
</div> </div>
</div> </div>
</div> </div>
@@ -1,3 +1,5 @@
using System.IO;
using System.Reflection;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Core.Interfaces; using QuantEngine.Core.Interfaces;
@@ -10,15 +12,81 @@ public class IndexModel : PageModel
{ {
private readonly IWorkspaceRepository _workspaceRepository; private readonly IWorkspaceRepository _workspaceRepository;
private readonly ICollectionRepository _collectionRepository; private readonly ICollectionRepository _collectionRepository;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<IndexModel> _logger; private readonly ILogger<IndexModel> _logger;
public int? ActiveUsersCount { get; set; } public int? ActiveUsersCount { get; set; }
public int? RecentRunsCount { get; set; } public int? RecentRunsCount { get; set; }
public bool IsDatabaseConnected { get; set; }
public string EnvironmentName => _environment.EnvironmentName;
public string AppVersion
{
get
{
var rawVersion = Assembly.GetEntryAssembly()
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
public IndexModel(IWorkspaceRepository workspaceRepository, ICollectionRepository collectionRepository, ILogger<IndexModel> logger) if (!string.IsNullOrEmpty(rawVersion) && rawVersion.StartsWith("quant_", StringComparison.OrdinalIgnoreCase))
{
return rawVersion;
}
var dateStr = DateTime.UtcNow.AddHours(9).ToString("yyyyMMdd");
string gitHash = "a9986fb";
try
{
var baseDir = AppContext.BaseDirectory;
var current = new DirectoryInfo(baseDir);
string? repoRoot = null;
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
repoRoot = current.FullName;
break;
}
current = current.Parent;
}
if (repoRoot != null)
{
var headPath = Path.Combine(repoRoot, ".git", "HEAD");
if (System.IO.File.Exists(headPath))
{
var headContent = System.IO.File.ReadAllText(headPath).Trim();
if (headContent.StartsWith("ref:"))
{
var refPath = Path.Combine(repoRoot, ".git", headContent.Substring(4).Trim());
if (System.IO.File.Exists(refPath))
{
var fullHash = System.IO.File.ReadAllText(refPath).Trim();
if (fullHash.Length >= 7)
gitHash = fullHash.Substring(0, 7);
}
}
else if (headContent.Length >= 7)
{
gitHash = headContent.Substring(0, 7);
}
}
}
}
catch {}
int runCount = 14;
return $"quant_{dateStr}.{runCount}.{gitHash}";
}
}
public IndexModel(
IWorkspaceRepository workspaceRepository,
ICollectionRepository collectionRepository,
IWebHostEnvironment environment,
ILogger<IndexModel> logger)
{ {
_workspaceRepository = workspaceRepository; _workspaceRepository = workspaceRepository;
_collectionRepository = collectionRepository; _collectionRepository = collectionRepository;
_environment = environment;
_logger = logger; _logger = logger;
} }
@@ -31,10 +99,16 @@ public class IndexModel : PageModel
var dashboard = await _collectionRepository.GetDashboardStateAsync(); var dashboard = await _collectionRepository.GetDashboardStateAsync();
RecentRunsCount = string.IsNullOrEmpty(dashboard?.LastRunId) ? 0 : 1; RecentRunsCount = string.IsNullOrEmpty(dashboard?.LastRunId) ? 0 : 1;
// These two queries only complete if the DB round-trip actually
// succeeded, so reaching this line is the real signal -- do not
// hardcode a static "정상"/"연결됨" badge independent of it.
IsDatabaseConnected = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Dashboard data loading failed"); _logger.LogError(ex, "Dashboard data loading failed");
IsDatabaseConnected = false;
} }
} }
} }
@@ -1,7 +1,7 @@
@page @page
@model QuantEngine.Web.Pages.Admin.Monitoring.IndexModel @model QuantEngine.Web.Pages.Admin.Monitoring.IndexModel
@{ @{
ViewData["Title"] = "모니터링 - QuantEngine"; ViewData["Title"] = "모니터링";
} }
<div class="page-header d-print-none"> <div class="page-header d-print-none">
@@ -113,17 +113,14 @@
<strong>데이터베이스</strong> <strong>데이터베이스</strong>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<span class="badge bg-success">연결 정상</span> @if (Model.IsDatabaseConnected)
</div> {
</div> <span class="badge bg-success">연결 정상</span>
</div> }
<div class="list-group-item"> else
<div class="row align-items-center"> {
<div class="col"> <span class="badge bg-danger">연결 끊김</span>
<strong>API 서버</strong> }
</div>
<div class="col-auto">
<span class="badge bg-success">운영 중</span>
</div> </div>
</div> </div>
</div> </div>
@@ -17,6 +17,7 @@ public class IndexModel : PageModel
public int FailedRuns24h { get; set; } public int FailedRuns24h { get; set; }
public DateTime? LastRefreshTime { get; set; } public DateTime? LastRefreshTime { get; set; }
public List<CollectionErrorRecord>? RecentErrors { get; set; } public List<CollectionErrorRecord>? RecentErrors { get; set; }
public bool IsDatabaseConnected { get; set; }
public IndexModel(ICollectionRepository collectionRepository, ILogger<IndexModel> logger) public IndexModel(ICollectionRepository collectionRepository, ILogger<IndexModel> logger)
{ {
@@ -60,12 +61,14 @@ public class IndexModel : PageModel
} }
RecentErrors = allErrors.OrderByDescending(e => e.CreatedAt).Take(10).ToList(); RecentErrors = allErrors.OrderByDescending(e => e.CreatedAt).Take(10).ToList();
IsDatabaseConnected = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to load monitoring data"); _logger.LogError(ex, "Failed to load monitoring data");
OngoingRuns = []; OngoingRuns = [];
RecentErrors = []; RecentErrors = [];
IsDatabaseConnected = false;
} }
} }
} }
@@ -1,7 +1,7 @@
@page @page
@model QuantEngine.Web.Pages.Admin.Operations.IndexModel @model QuantEngine.Web.Pages.Admin.Operations.IndexModel
@{ @{
ViewData["Title"] = "작업 관리 - QuantEngine"; ViewData["Title"] = "작업 관리";
} }
<div class="page-header d-print-none"> <div class="page-header d-print-none">
@@ -16,6 +16,18 @@
</div> </div>
<div class="page-body"> <div class="page-body">
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success" role="alert" style="background-color: rgba(76, 175, 80, 0.15); border: 1px solid rgba(76, 175, 80, 0.3); color: #81c784; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
@TempData["SuccessMessage"]
</div>
}
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger" role="alert" style="background-color: rgba(244, 67, 54, 0.15); border: 1px solid rgba(244, 67, 54, 0.3); color: #ff7675; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
@TempData["ErrorMessage"]
</div>
}
<div class="row row-deck row-cards"> <div class="row row-deck row-cards">
<!-- 예약된 작업 --> <!-- 예약된 작업 -->
<div class="col-12"> <div class="col-12">
@@ -56,7 +68,12 @@
} }
</td> </td>
<td> <td>
<a href="javascript:void(0)" class="btn btn-sm btn-link">수정</a> <form method="post" asp-page-handler="TriggerJob" class="d-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="jobId" value="@job.JobId" />
<button type="submit" class="btn btn-sm btn-success text-white">즉시 실행</button>
</form>
<a href="javascript:void(0)" class="btn btn-sm btn-link ms-2">수정</a>
</td> </td>
</tr> </tr>
} }
@@ -1,4 +1,7 @@
using Hangfire;
using Hangfire.Storage;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Web.Services; using QuantEngine.Web.Services;
@@ -24,51 +27,130 @@ public class IndexModel : PageModel
_logger = logger; _logger = logger;
} }
public async Task OnGetAsync() public Task OnGetAsync()
{ {
await LoadOperationsData(); LoadOperationsData();
return Task.CompletedTask;
} }
private async Task LoadOperationsData() public async Task<IActionResult> OnPostTriggerJobAsync(string jobId)
{
if (string.IsNullOrEmpty(jobId))
{
TempData["ErrorMessage"] = "올바르지 않은 작업 ID입니다.";
return RedirectToPage();
}
try
{
RecurringJob.TriggerJob(jobId);
TempData["SuccessMessage"] = $"작업 '{DescribeJobId(jobId)}'이(가) 즉시 실행 큐에 등록되었습니다.";
_logger.LogInformation("Manually triggered Hangfire recurring job: {JobId}", jobId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger Hangfire job: {JobId}", jobId);
TempData["ErrorMessage"] = $"작업 실행 실패: {ex.Message}";
}
return RedirectToPage();
}
private void LoadOperationsData()
{ {
try try
{ {
LastRefreshTime = DateTime.UtcNow; LastRefreshTime = DateTime.UtcNow;
ScheduledJobs = new List<ScheduledJobInfo> // All data below comes directly from Hangfire's own storage
{ // (JobStorage.Current), which already backs the real
new("KIS 데이터 수집", "매일 09:00", DateTime.UtcNow.AddHours(1), true), // recurring jobs registered in SchedulerService.InitializeSchedules
new("포트폴리오 스냅샷", "매일 17:00", DateTime.UtcNow.AddHours(8), true), // (daily-collection, hourly-price-update, weekly-report,
new("일일 리포트 생성", "매일 08:00", DateTime.UtcNow.AddHours(-1), true), // monthly-optimization) and every job it has actually run.
new("데이터 정리", "주 1회 (월)", DateTime.UtcNow.AddDays(5), true) // This page previously returned four fabricated job names and
}; // fake execution timestamps with no connection to Hangfire at all.
using IStorageConnection connection = JobStorage.Current.GetConnection();
var monitoringApi = JobStorage.Current.GetMonitoringApi();
RecentExecutions = new List<JobExecutionInfo> var recurringJobs = connection.GetRecurringJobs();
{ ScheduledJobs = recurringJobs
new("포트폴리오 스냅샷", DateTime.UtcNow.AddHours(-2), DateTime.UtcNow.AddHours(-2).AddSeconds(45), true), .Select(j => new ScheduledJobInfo(
new("KIS 데이터 수집", DateTime.UtcNow.AddHours(-4), DateTime.UtcNow.AddHours(-4).AddSeconds(120), true), j.Id,
new("일일 리포트 생성", DateTime.UtcNow.AddHours(-6), DateTime.UtcNow.AddHours(-6).AddSeconds(30), true), DescribeJobId(j.Id),
new("KIS 데이터 수집", DateTime.UtcNow.AddHours(-24), DateTime.UtcNow.AddHours(-24).AddSeconds(110), true) DescribeCron(j.Cron),
}; j.NextExecution,
string.IsNullOrEmpty(j.Error)))
.ToList();
TotalJobsCount = ScheduledJobs.Count; TotalJobsCount = ScheduledJobs.Count;
ActiveJobsCount = ScheduledJobs.Count(j => j.IsEnabled); ActiveJobsCount = ScheduledJobs.Count(j => j.IsEnabled);
InactiveJobsCount = TotalJobsCount - ActiveJobsCount; InactiveJobsCount = TotalJobsCount - ActiveJobsCount;
PendingJobsCount = 0;
IsJobProcessorRunning = true;
StatusMessage = "모든 작업이 정상적으로 실행 중입니다.";
_logger.LogInformation("Operations data loaded successfully"); var succeeded = monitoringApi.SucceededJobs(0, 10)
.Select(kv => new JobExecutionInfo(
kv.Value.Job?.Method.Name ?? kv.Key,
kv.Value.SucceededAt ?? DateTime.UtcNow,
kv.Value.SucceededAt,
true));
var failed = monitoringApi.FailedJobs(0, 10)
.Select(kv => new JobExecutionInfo(
kv.Value.Job?.Method.Name ?? kv.Key,
kv.Value.FailedAt ?? DateTime.UtcNow,
kv.Value.FailedAt,
false));
RecentExecutions = succeeded.Concat(failed)
.OrderByDescending(e => e.StartedAt)
.Take(10)
.ToList();
var servers = monitoringApi.Servers();
IsJobProcessorRunning = servers.Count > 0;
PendingJobsCount = (int)monitoringApi.EnqueuedCount("default");
StatusMessage = IsJobProcessorRunning
? $"{servers.Count}개 워커 서버 운영 중"
: "Hangfire 서버가 등록되어 있지 않습니다";
_logger.LogInformation("Operations data loaded from Hangfire ({Count} recurring jobs, {Servers} servers)",
ScheduledJobs.Count, servers.Count);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to load operations data"); _logger.LogError(ex, "Failed to load operations data from Hangfire");
ScheduledJobs = []; ScheduledJobs = [];
RecentExecutions = []; RecentExecutions = [];
StatusMessage = "데이터 로딩 중 오류가 발생했습니다."; IsJobProcessorRunning = false;
StatusMessage = "Hangfire 상태 조회 실패 - 로그 확인 필요";
} }
} }
// Purely cosmetic: renders the actual CRON expression from Hangfire in
// readable Korean where we recognize the exact pattern SchedulerService
// registers, falling back to the raw CRON string for anything else so
// this never hides or fabricates a schedule that isn't really there.
private static string DescribeCron(string cron) => cron switch
{
"0 9 * * *" => "매일 09:00",
"0 9-15 * * 1-5" => "평일 09-15시 매시",
"0 17 * * 5" => "매주 금요일 17:00",
"0 2 1 * *" => "매월 1일 02:00",
_ => cron,
};
// Same rationale as DescribeCron: display label for a real Hangfire
// recurring-job id, not a substitute for it. Unknown ids pass through
// unchanged.
private static string DescribeJobId(string jobId) => jobId switch
{
"daily-collection" => "일일 데이터 수집",
"hourly-price-update" => "시간별 가격 갱신",
"weekly-report" => "주간 리포트 생성",
"monthly-optimization" => "월간 최적화",
_ => jobId,
};
} }
public record ScheduledJobInfo(string JobName, string Schedule, DateTime? NextRun, bool IsEnabled); public record ScheduledJobInfo(string JobId, string JobName, string Schedule, DateTime? NextRun, bool IsEnabled);
public record JobExecutionInfo(string JobName, DateTime StartedAt, DateTime? CompletedAt, bool IsSuccess); public record JobExecutionInfo(string JobName, DateTime StartedAt, DateTime? CompletedAt, bool IsSuccess);
@@ -53,11 +53,9 @@
</td> </td>
<td>@(user.CreatedAt ?? "-")</td> <td>@(user.CreatedAt ?? "-")</td>
<td> <td>
<a href="/Admin/Users/@user.Username/Edit" class="btn btn-sm btn-link">수정</a> <a asp-page="./Edit" asp-route-username="@user.Username" class="btn btn-sm btn-link">수정</a>
<form method="post" style="display:inline;" onsubmit="return confirm('이 사용자를 비활성화하시겠습니까?');"> <form method="post" asp-page-handler="Delete" asp-route-username="@user.Username" style="display:inline;" onsubmit="return confirm('이 사용자를 비활성화하시겠습니까?');">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<input type="hidden" name="handler" value="delete" />
<input type="hidden" name="username" value="@user.Username" />
<button type="submit" class="btn btn-sm btn-link text-danger">비활성화</button> <button type="submit" class="btn btn-sm btn-link text-danger">비활성화</button>
</form> </form>
</td> </td>
@@ -1,5 +1,8 @@
@{ @{
Layout = null; Layout = null;
var currentPath = Context.Request.Path.Value?.ToLowerInvariant() ?? "";
string NavActive(string href) => currentPath.StartsWith(href.ToLowerInvariant()) ? "active" : "";
} }
<!DOCTYPE html> <!DOCTYPE html>
@@ -16,233 +19,106 @@
<!-- Custom Admin CSS --> <!-- Custom Admin CSS -->
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
}
.wrapper {
display: flex;
flex-direction: column;
height: 100vh;
}
.page {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 260px;
background-color: #2c3e50;
color: white;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar-brand {
padding: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-brand a {
color: white;
text-decoration: none;
font-weight: 600;
font-size: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sidebar-nav {
padding: 1rem 0;
}
.sidebar-nav .nav-link {
padding: 0.75rem 1.5rem;
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.75rem;
transition: all 0.2s;
}
.sidebar-nav .nav-link:hover,
.sidebar-nav .nav-link.active {
color: white;
background-color: rgba(255, 255, 255, 0.1);
}
.sidebar-nav .nav-link i {
width: 1.25rem;
text-align: center;
}
.main-content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.topbar {
background-color: white;
border-bottom: 1px solid #e0e0e0;
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
height: 64px;
}
.topbar-left {
font-weight: 600;
color: #2c3e50;
}
.topbar-right {
display: flex;
gap: 1rem;
align-items: center;
}
.topbar-right .btn {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.page-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
background-color: #f5f7fa;
}
.container-xl {
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
.page-header {
margin-bottom: 2rem;
}
.page-header .page-title {
font-size: 1.75rem;
font-weight: 600;
color: #2c3e50;
margin: 0;
}
.page-header .page-subtitle {
color: #7a8a99;
font-size: 0.875rem;
}
@@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -260px;
height: 100vh;
z-index: 999;
transition: left 0.3s;
}
.sidebar.show {
left: 0;
}
.main-content {
width: 100%;
}
}
</style>
</head> </head>
<body> <body>
<div class="wrapper"> <div class="page">
<!-- Topbar --> <!-- Sidebar (left) -->
<div class="topbar"> <aside class="navbar navbar-vertical navbar-expand-lg navbar-dark" data-bs-theme="dark">
<div class="topbar-left"> <div class="container-fluid">
<span id="page-title">QuantEngine Admin</span> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="메뉴 토글">
</div> <span class="navbar-toggler-icon"></span>
<div class="topbar-right"> </button>
<a href="/Account/Logout" class="btn btn-sm btn-outline-danger"> <h1 class="navbar-brand navbar-brand-autodark">
<i class="ti ti-logout me-1"></i> 로그아웃 <a href="/Admin/Dashboard" class="d-flex align-items-center gap-2 text-decoration-none">
</a>
</div>
</div>
<!-- Main -->
<div class="page">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-brand">
<a href="/Admin/Dashboard">
<i class="ti ti-chart-line" style="font-size: 1.5rem;"></i> <i class="ti ti-chart-line" style="font-size: 1.5rem;"></i>
<span>QuantEngine</span> <span>QuantEngine</span>
</a> </a>
</div> </h1>
<div class="collapse navbar-collapse" id="sidebar-menu">
<div class="sidebar-nav"> <ul class="navbar-nav pt-lg-3">
<a href="/Admin/Dashboard" class="nav-link"> <li class="nav-item">
<i class="ti ti-dashboard"></i> <a class="nav-link @NavActive("/Admin/Dashboard")" href="/Admin/Dashboard">
<span>대시보드</span> <span class="nav-link-icon"><i class="ti ti-dashboard"></i></span>
</a> <span class="nav-link-title">대시보드</span>
<a href="/Admin/Collection" class="nav-link"> </a>
<i class="ti ti-database"></i> </li>
<span>데이터 수집</span> <li class="nav-item">
</a> <a class="nav-link @NavActive("/Admin/Collection")" href="/Admin/Collection">
<a href="/Admin/Monitoring" class="nav-link"> <span class="nav-link-icon"><i class="ti ti-database"></i></span>
<i class="ti ti-eye"></i> <span class="nav-link-title">데이터 수집</span>
<span>모니터링</span> </a>
</a> </li>
<a href="/Admin/Users" class="nav-link"> <li class="nav-item">
<i class="ti ti-users"></i> <a class="nav-link @NavActive("/Admin/Monitoring")" href="/Admin/Monitoring">
<span>사용자 관리</span> <span class="nav-link-icon"><i class="ti ti-eye"></i></span>
</a> <span class="nav-link-title">모니터링</span>
<a href="/Admin/Operations" class="nav-link"> </a>
<i class="ti ti-settings"></i> </li>
<span>운영 관리</span> <li class="nav-item">
</a> <a class="nav-link @NavActive("/Admin/Users")" href="/Admin/Users">
</div> <span class="nav-link-icon"><i class="ti ti-users"></i></span>
</nav> <span class="nav-link-title">사용자 관리</span>
</a>
<!-- Page Content --> </li>
<div class="main-content"> <li class="nav-item">
<div class="page-content"> <a class="nav-link @NavActive("/Admin/Operations")" href="/Admin/Operations">
<div class="container-xl"> <span class="nav-link-icon"><i class="ti ti-settings"></i></span>
@RenderBody() <span class="nav-link-title">운영 관리</span>
</div> </a>
</li>
</ul>
</div> </div>
</div> </div>
</aside>
<!-- Topbar -->
<header class="navbar navbar-expand-md navbar-light d-print-none">
<div class="container-xl">
<div class="navbar-nav flex-row flex-fill justify-content-between align-items-center">
<span class="fw-medium">@ViewData["Title"]</span>
<a href="/Account/Logout" class="btn btn-sm btn-outline-danger">
<i class="ti ti-logout me-1"></i> 로그아웃
</a>
</div>
</div>
</header>
<div class="page-wrapper">
<!-- Center content -->
<div class="page-body">
<div class="container-xl">
@RenderBody()
</div>
</div>
<!-- Footer -->
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<div class="row text-center align-items-center flex-row-reverse">
<div class="col-lg-auto ms-lg-auto">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item">
<a href="/Admin/Dashboard" class="link-secondary">대시보드</a>
</li>
<li class="list-inline-item">
<a href="/Admin/Operations" class="link-secondary">운영 관리</a>
</li>
</ul>
</div>
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item">
&copy; @DateTime.UtcNow.Year QuantEngine
</li>
</ul>
</div>
</div>
</div>
</footer>
</div> </div>
</div> </div>
<!-- Tabler JS --> <!-- Tabler JS (bundles Bootstrap JS, incl. the Collapse plugin used by the mobile sidebar toggle above) -->
<script src="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/js/tabler.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/js/tabler.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const links = document.querySelectorAll('.sidebar-nav .nav-link');
const currentPath = window.location.pathname.toLowerCase();
links.forEach(link => {
const href = link.getAttribute('href').toLowerCase();
if (currentPath.startsWith(href)) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
});
});
</script>
</body> </body>
</html> </html>
+28
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.DataProtection;
using QuantEngine.Infrastructure.Data; using QuantEngine.Infrastructure.Data;
using QuantEngine.Infrastructure.Repositories; using QuantEngine.Infrastructure.Repositories;
using QuantEngine.Infrastructure.Services; using QuantEngine.Infrastructure.Services;
@@ -22,6 +23,22 @@ try
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog(); builder.Host.UseSerilog();
// Data Protection: without this, ASP.NET Core derives its key-ring
// discriminator from the app's physical content root path. Every
// deployment lands in a brand-new directory
// (~/deployments/quantengine_{tag}_{hash}/), so the discriminator
// changed on every single deploy and every previously-issued auth
// cookie became undecryptable -- forcing all users to log in again
// after each release. SetApplicationName pins a stable discriminator;
// PersistKeysToFileSystem points at a location outside the versioned
// deployment folders so the actual key material also survives restarts.
var dataProtectionKeysPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"quantengine-keys");
builder.Services.AddDataProtection()
.SetApplicationName("QuantEngine")
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath));
// Authentication & Authorization // Authentication & Authorization
builder.Services.AddAuthentication(opts => builder.Services.AddAuthentication(opts =>
{ {
@@ -79,12 +96,23 @@ try
// Repository Services // Repository Services
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>(); builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>(); builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
builder.Services.AddScoped<INormalizedLearningStore, NormalizedLearningStore>();
builder.Services.AddScoped<ILearningDatasetReader, LearningDatasetReader>();
builder.Services.AddScoped<DecisionLearningService>();
builder.Services.AddScoped<LearningDatasetService>();
builder.Services.AddSingleton<GatherTradingDataParser>();
builder.Services.AddScoped<JsonSeedIngestionService>();
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>(); builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
builder.Services.AddScoped<HistoryIngestionService>(); builder.Services.AddScoped<HistoryIngestionService>();
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>(); builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>(); builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>(); builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
// Collection Pipeline Services
builder.Services.AddScoped<SourcePriorityResolver>();
builder.Services.AddScoped<PriceDataNormalizer>();
builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
// Hangfire Background Jobs // Hangfire Background Jobs
try try
{ {
@@ -18,25 +18,10 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Exclude client project files from server build to avoid duplicate compilations -->
<!-- BUT preserve Client\wwwroot for static web assets -->
<Compile Remove="Client\**" />
<EmbeddedResource Remove="Client\**" />
<None Remove="Client\**" />
</ItemGroup>
<ItemGroup>
<!-- Only remove non-wwwroot Client content -->
<Content Remove="Client\**" />
<Content Include="Client\wwwroot\**" CopyToPublishDirectory="Never" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
@@ -9,9 +9,10 @@ public interface IIpLockoutService
public class IpLockoutService : IIpLockoutService public class IpLockoutService : IIpLockoutService
{ {
private readonly Dictionary<string, (int Attempts, DateTime LockedUntil)> _attemptLog = []; private readonly Dictionary<string, (int Attempts, int LockoutCount, DateTime LockedUntil)> _attemptLog = [];
private const int MaxFailedAttempts = 3; private const int MaxFailedAttempts = 3;
private const int LockoutDurationMinutes = 15; private const int LockoutDurationMinutes = 15;
private const int MaxLockoutDurationMinutes = 1440; // 24 hours cap
private readonly object _lock = new(); private readonly object _lock = new();
public bool IsLockedOut(string ipAddress) public bool IsLockedOut(string ipAddress)
@@ -23,7 +24,11 @@ public class IpLockoutService : IIpLockoutService
if (DateTime.UtcNow < record.LockedUntil) if (DateTime.UtcNow < record.LockedUntil)
return true; return true;
_attemptLog.Remove(ipAddress); // Reset failed attempts but keep the LockoutCount to calculate progressive delay on next failure
if (record.Attempts >= MaxFailedAttempts)
{
_attemptLog[ipAddress] = (0, record.LockoutCount, DateTime.MinValue);
}
} }
return false; return false;
@@ -39,14 +44,19 @@ public class IpLockoutService : IIpLockoutService
record.Attempts++; record.Attempts++;
if (record.Attempts >= MaxFailedAttempts) if (record.Attempts >= MaxFailedAttempts)
{ {
record.LockedUntil = DateTime.UtcNow.AddMinutes(LockoutDurationMinutes); // Calculate exponential progressive lockout duration: 15 * 2^LockoutCount
var durationMinutes = LockoutDurationMinutes * Math.Pow(2, record.LockoutCount);
durationMinutes = Math.Min(durationMinutes, MaxLockoutDurationMinutes);
record.LockedUntil = DateTime.UtcNow.AddMinutes(durationMinutes);
record.LockoutCount++;
} }
_attemptLog[ipAddress] = record; _attemptLog[ipAddress] = record;
} }
else else
{ {
_attemptLog[ipAddress] = (1, DateTime.UtcNow); _attemptLog[ipAddress] = (1, 0, DateTime.MinValue);
} }
} }
} }
@@ -5,7 +5,9 @@ using Hangfire.PostgreSql;
using Hangfire.MemoryStorage; using Hangfire.MemoryStorage;
using System.Linq.Expressions; using System.Linq.Expressions;
using QuantEngine.Application.Services; using QuantEngine.Application.Services;
using QuantEngine.Application.Interfaces;
using QuantEngine.Infrastructure.Data; using QuantEngine.Infrastructure.Data;
using Microsoft.Extensions.Configuration;
namespace QuantEngine.Web.Services; namespace QuantEngine.Web.Services;
@@ -17,15 +19,21 @@ public class SchedulerService
private readonly ILogger<SchedulerService> _logger; private readonly ILogger<SchedulerService> _logger;
private readonly IBackgroundJobClient _jobClient; private readonly IBackgroundJobClient _jobClient;
private readonly IRecurringJobManager _recurringJobManager; private readonly IRecurringJobManager _recurringJobManager;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IConfiguration _configuration;
public SchedulerService( public SchedulerService(
ILogger<SchedulerService> logger, ILogger<SchedulerService> logger,
IBackgroundJobClient jobClient, IBackgroundJobClient jobClient,
IRecurringJobManager recurringJobManager) IRecurringJobManager recurringJobManager,
IServiceScopeFactory scopeFactory,
IConfiguration configuration)
{ {
_logger = logger; _logger = logger;
_jobClient = jobClient; _jobClient = jobClient;
_recurringJobManager = recurringJobManager; _recurringJobManager = recurringJobManager;
_scopeFactory = scopeFactory;
_configuration = configuration;
} }
/// <summary> /// <summary>
@@ -89,14 +97,22 @@ public class SchedulerService
// List of tickers to collect // List of tickers to collect
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }; var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" };
foreach (var ticker in tickers) // Create scope for scoped services
{ using var scope = _scopeFactory.CreateScope();
// Simulate data collection var orchestrator = scope.ServiceProvider.GetRequiredService<ICollectionOrchestrator>();
await Task.Delay(100);
_logger.LogInformation("Collected data for ticker: {Ticker}", ticker);
}
_logger.LogInformation("Daily data collection completed at {Time}", DateTime.Now); // Build runId with timestamp
var runId = $"daily-{DateTime.Now:yyyyMMdd-HHmmss}";
// Read account mode from configuration (default to "mock")
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
// Execute collection
var result = await orchestrator.RunCollectionAsync(runId, accountMode, tickers.ToList());
// Log completion
_logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors",
runId, result.SuccessCount, result.ErrorCount);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -289,8 +305,16 @@ public static class HangfireServiceExtensions
Authorization = new[] { new HangfireAuthorizationFilter() } Authorization = new[] { new HangfireAuthorizationFilter() }
}); });
// Initialize schedules // Initialize schedules. SchedulerService is registered as Scoped
var schedulerService = serviceProvider.GetRequiredService<SchedulerService>(); // (AddScoped above), so it cannot be resolved directly from the
// root provider passed in here (app.Services) -- doing so silently
// failed every startup with "Cannot resolve scoped service
// 'SchedulerService' from root provider", meaning the recurring
// jobs were never being freshly registered/updated on boot (they
// only appeared to work because Hangfire persists them in
// PostgreSQL from whichever startup last managed to run this).
using var scope = serviceProvider.CreateScope();
var schedulerService = scope.ServiceProvider.GetRequiredService<SchedulerService>();
schedulerService.InitializeSchedules(); schedulerService.InitializeSchedules();
return app; return app;
@@ -304,8 +328,7 @@ public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
{ {
public bool Authorize(DashboardContext context) public bool Authorize(DashboardContext context)
{ {
// TODO: Implement proper authorization check var httpContext = context.GetHttpContext();
// For now, allow all in development return httpContext.User.Identity?.IsAuthenticated == true;
return true;
} }
} }
@@ -1,18 +0,0 @@
2026-07-11 17:02:14.217 +09:00 [FTL] Application terminated unexpectedly
System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: QuantEngine.Core.Interfaces.IKisApiClient Lifetime: Scoped ImplementationType: QuantEngine.Infrastructure.Services.KisApiClient': Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'.)
---> System.InvalidOperationException: Error while validating the service descriptor 'ServiceType: QuantEngine.Core.Interfaces.IKisApiClient Lifetime: Scoped ImplementationType: QuantEngine.Infrastructure.Services.KisApiClient': Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'.
---> System.InvalidOperationException: Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'.
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateArgumentCallSites(ServiceIdentifier serviceIdentifier, Type implementationType, CallSiteChain callSiteChain, ParameterInfo[] parameters, Boolean throwIfCallSiteNotFound)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateConstructorCallSite(ResultCache lifetime, ServiceIdentifier serviceIdentifier, Type implementationType, CallSiteChain callSiteChain)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateExact(ServiceDescriptor descriptor, ServiceIdentifier serviceIdentifier, CallSiteChain callSiteChain, Int32 slot)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.GetCallSite(ServiceDescriptor serviceDescriptor, CallSiteChain callSiteChain)
at Microsoft.Extensions.DependencyInjection.ServiceProvider.ValidateService(ServiceDescriptor descriptor)
--- End of inner exception stack trace ---
at Microsoft.Extensions.DependencyInjection.ServiceProvider.ValidateService(ServiceDescriptor descriptor)
at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(ICollection`1 serviceDescriptors, ServiceProviderOptions options)
--- End of inner exception stack trace ---
at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(ICollection`1 serviceDescriptors, ServiceProviderOptions options)
at Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(IServiceCollection services, ServiceProviderOptions options)
at Microsoft.Extensions.Hosting.HostApplicationBuilder.Build()
at Microsoft.AspNetCore.Builder.WebApplicationBuilder.Build()
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 99
@@ -0,0 +1,381 @@
2026-07-11 18:41:46.431 +09:00 [INF] Registered 10 endpoints in 164 milliseconds.
2026-07-11 18:41:46.473 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-11 18:41:50.463 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:41:50.464 +09:00 [ERR] ❌ Database migration failed
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
2026-07-11 18:41:50.476 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:41:50.498 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2026-07-11 18:42:27.125 +09:00 [INF] Registered 10 endpoints in 178 milliseconds.
2026-07-11 18:42:27.164 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-11 18:42:31.234 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:42:31.234 +09:00 [ERR] ❌ Database migration failed
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
2026-07-11 18:42:31.240 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:42:31.281 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2026-07-11 18:42:35.322 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
2026-07-11 18:42:35.536 +09:00 [ERR] Hosting failed to start
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
2026-07-11 18:42:35.543 +09:00 [FTL] Application terminated unexpectedly
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(CancellationToken cancellationToken)
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host)
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 172
2026-07-11 18:47:26.979 +09:00 [INF] Registered 10 endpoints in 173 milliseconds.
2026-07-11 18:47:27.018 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-11 18:47:31.144 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:47:31.144 +09:00 [ERR] ❌ Database migration failed
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
2026-07-11 18:47:31.149 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:47:31.177 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2026-07-11 18:47:58.996 +09:00 [INF] Registered 10 endpoints in 163 milliseconds.
2026-07-11 18:47:59.034 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-11 18:48:03.135 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:48:03.136 +09:00 [ERR] ❌ Database migration failed
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
2026-07-11 18:48:03.142 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:48:03.166 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2026-07-11 18:48:07.181 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
2026-07-11 18:48:07.453 +09:00 [ERR] Hosting failed to start
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
2026-07-11 18:48:07.460 +09:00 [FTL] Application terminated unexpectedly
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(CancellationToken cancellationToken)
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host)
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 172
2026-07-11 18:49:42.328 +09:00 [INF] Registered 10 endpoints in 160 milliseconds.
2026-07-11 18:49:42.368 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-11 18:49:46.632 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:49:46.632 +09:00 [ERR] ❌ Database migration failed
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
2026-07-11 18:49:46.635 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:49:46.655 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2026-07-11 18:49:50.782 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
2026-07-11 18:49:50.940 +09:00 [INF] Now listening on: http://localhost:5265
2026-07-11 18:49:50.946 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
2026-07-11 18:49:50.946 +09:00 [INF] Using the following options for Hangfire Server:
Worker count: 32
Listening queues: 'default'
Shutdown timeout: 00:00:15
Schedule polling interval: 00:00:15
2026-07-11 18:49:50.966 +09:00 [INF] Application started. Press Ctrl+C to shut down.
2026-07-11 18:49:50.966 +09:00 [INF] Hosting environment: Development
2026-07-11 18:49:50.966 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
2026-07-11 18:49:51.046 +09:00 [INF] Server kimjaehyun-note:18540:24fc9869 successfully announced in 77.2082 ms
2026-07-11 18:49:51.050 +09:00 [INF] Server kimjaehyun-note:18540:24fc9869 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
2026-07-11 18:49:51.076 +09:00 [INF] Server kimjaehyun-note:18540:24fc9869 all the dispatchers started
2026-07-11 18:50:18.938 +09:00 [INF] Registered 10 endpoints in 161 milliseconds.
2026-07-11 18:50:18.977 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-11 18:50:23.249 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:50:23.250 +09:00 [ERR] ❌ Database migration failed
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
2026-07-11 18:50:23.256 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:50:23.283 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2026-07-11 18:50:27.388 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
2026-07-11 18:50:27.569 +09:00 [INF] Now listening on: http://localhost:5265
2026-07-11 18:50:27.577 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
2026-07-11 18:50:27.577 +09:00 [INF] Using the following options for Hangfire Server:
Worker count: 32
Listening queues: 'default'
Shutdown timeout: 00:00:15
Schedule polling interval: 00:00:15
2026-07-11 18:50:27.589 +09:00 [INF] Application started. Press Ctrl+C to shut down.
2026-07-11 18:50:27.590 +09:00 [INF] Hosting environment: Development
2026-07-11 18:50:27.590 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
2026-07-11 18:50:27.646 +09:00 [INF] Server kimjaehyun-note:25888:2b65733c successfully announced in 54.5404 ms
2026-07-11 18:50:27.648 +09:00 [INF] Server kimjaehyun-note:25888:2b65733c is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
2026-07-11 18:50:27.667 +09:00 [INF] Server kimjaehyun-note:25888:2b65733c all the dispatchers started
2026-07-11 18:50:40.967 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-11 18:50:40.972 +09:00 [WRN] Failed to determine the https port for redirect.
2026-07-11 18:50:41.007 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-11 18:50:41.023 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-11 18:50:41.030 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-11 18:50:41.031 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-11 18:50:41.033 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-11 18:50:41.033 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-11 18:50:41.058 +09:00 [INF] Executed page /Account/Login in 32.7802ms
2026-07-11 18:50:41.059 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-11 18:50:41.060 +09:00 [INF] HTTP GET /Account/Login responded 200 in 88.7566 ms
2026-07-11 18:50:41.062 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 94.7893ms
2026-07-11 18:50:41.174 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
2026-07-11 18:50:41.178 +09:00 [ERR] HTTP GET /Admin/Dashboard responded 500 in 4.0554 ms
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
2026-07-11 18:50:41.181 +09:00 [ERR] An unhandled exception has occurred while executing the request.
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
2026-07-11 18:50:41.185 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 500 null text/plain; charset=utf-8 12.0749ms
2026-07-11 18:50:41.298 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-11 18:50:41.303 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-11 18:50:41.303 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-11 18:50:41.304 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-11 18:50:41.304 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-11 18:50:41.304 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-11 18:50:41.304 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-11 18:50:41.310 +09:00 [INF] Executed page /Account/Login in 6.8704ms
2026-07-11 18:50:41.310 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-11 18:50:41.310 +09:00 [INF] HTTP GET /Account/Login responded 200 in 12.5730 ms
2026-07-11 18:50:41.311 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 12.9475ms
2026-07-11 18:50:45.183 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
2026-07-11 18:50:45.185 +09:00 [ERR] HTTP GET /Admin/Dashboard responded 500 in 1.8220 ms
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
2026-07-11 18:50:45.186 +09:00 [ERR] An unhandled exception has occurred while executing the request.
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
2026-07-11 18:50:45.186 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 500 null text/plain; charset=utf-8 2.8989ms
2026-07-11 18:51:53.325 +09:00 [INF] Registered 10 endpoints in 159 milliseconds.
2026-07-11 18:51:53.369 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-11 18:51:57.672 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:51:57.672 +09:00 [ERR] ❌ Database migration failed
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
2026-07-11 18:51:57.699 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-11 18:51:57.728 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2026-07-11 18:52:01.841 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
2026-07-11 18:52:02.012 +09:00 [INF] Now listening on: http://localhost:5265
2026-07-11 18:52:02.023 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
2026-07-11 18:52:02.024 +09:00 [INF] Using the following options for Hangfire Server:
Worker count: 32
Listening queues: 'default'
Shutdown timeout: 00:00:15
Schedule polling interval: 00:00:15
2026-07-11 18:52:02.041 +09:00 [INF] Application started. Press Ctrl+C to shut down.
2026-07-11 18:52:02.041 +09:00 [INF] Hosting environment: Development
2026-07-11 18:52:02.041 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
2026-07-11 18:52:02.106 +09:00 [INF] Server kimjaehyun-note:28380:a220bb7a successfully announced in 61.5885 ms
2026-07-11 18:52:02.108 +09:00 [INF] Server kimjaehyun-note:28380:a220bb7a is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
2026-07-11 18:52:02.115 +09:00 [INF] Server kimjaehyun-note:28380:a220bb7a all the dispatchers started
2026-07-11 18:52:13.775 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-11 18:52:13.780 +09:00 [WRN] Failed to determine the https port for redirect.
2026-07-11 18:52:13.833 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-11 18:52:13.849 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-11 18:52:13.855 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-11 18:52:13.856 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-11 18:52:13.858 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-11 18:52:13.859 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-11 18:52:13.883 +09:00 [INF] Executed page /Account/Login in 32.0627ms
2026-07-11 18:52:13.884 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-11 18:52:13.885 +09:00 [INF] HTTP GET /Account/Login responded 200 in 106.3802 ms
2026-07-11 18:52:13.887 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 112.9483ms
2026-07-11 18:52:14.003 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
2026-07-11 18:52:14.011 +09:00 [INF] Authorization failed. These requirements were not met:
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
2026-07-11 18:52:14.013 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
2026-07-11 18:52:14.013 +09:00 [INF] HTTP GET /Admin/Dashboard responded 302 in 10.4176 ms
2026-07-11 18:52:14.014 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 302 0 null 11.3635ms
2026-07-11 18:52:14.120 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
2026-07-11 18:52:14.124 +09:00 [INF] Authorization failed. These requirements were not met:
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
2026-07-11 18:52:14.124 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
2026-07-11 18:52:14.125 +09:00 [INF] HTTP GET /Admin/Dashboard responded 302 in 4.4516 ms
2026-07-11 18:52:14.125 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 302 0 null 4.7369ms
2026-07-11 18:52:14.308 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
2026-07-11 18:52:14.309 +09:00 [INF] Authorization failed. These requirements were not met:
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
2026-07-11 18:52:14.310 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
2026-07-11 18:52:14.310 +09:00 [INF] HTTP GET /Admin/Dashboard responded 302 in 1.4155 ms
2026-07-11 18:52:14.310 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 302 0 null 1.7032ms
2026-07-11 18:52:14.410 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Users - null null
2026-07-11 18:52:14.411 +09:00 [INF] Authorization failed. These requirements were not met:
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
2026-07-11 18:52:14.411 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
2026-07-11 18:52:14.411 +09:00 [INF] HTTP GET /Admin/Users responded 302 in 0.4739 ms
2026-07-11 18:52:14.411 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Users - 302 0 null 0.7314ms
2026-07-11 18:52:14.523 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Collection - null null
2026-07-11 18:52:14.523 +09:00 [INF] Authorization failed. These requirements were not met:
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
2026-07-11 18:52:14.523 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
2026-07-11 18:52:14.524 +09:00 [INF] HTTP GET /Admin/Collection responded 302 in 0.6342 ms
2026-07-11 18:52:14.524 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Collection - 302 0 null 1.6071ms
2026-07-11 18:52:14.642 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - null null
2026-07-11 18:52:14.643 +09:00 [INF] Authorization failed. These requirements were not met:
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
2026-07-11 18:52:14.643 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
2026-07-11 18:52:14.643 +09:00 [INF] HTTP GET /Admin/Monitoring responded 302 in 0.4305 ms
2026-07-11 18:52:14.643 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - 302 0 null 0.677ms
2026-07-11 18:52:14.741 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Operations - null null
2026-07-11 18:52:14.741 +09:00 [INF] Authorization failed. These requirements were not met:
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
2026-07-11 18:52:14.742 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
2026-07-11 18:52:14.742 +09:00 [INF] HTTP GET /Admin/Operations responded 302 in 0.4306 ms
2026-07-11 18:52:14.742 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Operations - 302 0 null 0.6763ms
2026-07-11 21:04:06.174 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
2026-07-11 21:04:06.177 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
2026-07-11 21:04:06.177 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
2026-07-11 21:04:06.178 +09:00 [INF] HTTP GET /login responded 302 in 2.1715 ms
2026-07-11 21:04:06.178 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 3.7332ms
2026-07-11 21:04:06.191 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-11 21:04:06.194 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-11 21:04:06.195 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-11 21:04:06.198 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-11 21:04:06.199 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-11 21:04:06.199 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-11 21:04:06.200 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-11 21:04:06.212 +09:00 [INF] Executed page /Account/Login in 16.6942ms
2026-07-11 21:04:06.212 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-11 21:04:06.212 +09:00 [INF] HTTP GET /Account/Login responded 200 in 21.4926 ms
2026-07-11 21:04:06.213 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 22.1426ms
2026-07-11 21:04:06.222 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
2026-07-11 21:04:06.223 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
2026-07-11 21:04:06.223 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
2026-07-11 21:04:06.223 +09:00 [INF] HTTP GET /login responded 302 in 0.5991 ms
2026-07-11 21:04:06.223 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 1.2095ms
2026-07-11 21:04:06.226 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-11 21:04:06.227 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-11 21:04:06.227 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-11 21:04:06.231 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-11 21:04:06.232 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-11 21:04:06.232 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-11 21:04:06.233 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-11 21:04:06.238 +09:00 [INF] Executed page /Account/Login in 10.6054ms
2026-07-11 21:04:06.238 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-11 21:04:06.238 +09:00 [INF] HTTP GET /Account/Login responded 200 in 11.7060 ms
2026-07-11 21:04:06.238 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 12.2449ms
2026-07-11 21:05:05.597 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
2026-07-11 21:05:05.597 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
2026-07-11 21:05:05.598 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
2026-07-11 21:05:05.598 +09:00 [INF] HTTP GET /login responded 302 in 0.4316 ms
2026-07-11 21:05:05.598 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 0.8539ms
2026-07-11 21:05:05.606 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-11 21:05:05.606 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-11 21:05:05.606 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-11 21:05:05.606 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-11 21:05:05.606 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-11 21:05:05.607 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-11 21:05:05.607 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-11 21:05:05.607 +09:00 [INF] Executed page /Account/Login in 1.1238ms
2026-07-11 21:05:05.607 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-11 21:05:05.607 +09:00 [INF] HTTP GET /Account/Login responded 200 in 1.6523 ms
2026-07-11 21:05:05.607 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 1.9459ms
2026-07-11 21:05:05.611 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
2026-07-11 21:05:05.612 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
2026-07-11 21:05:05.612 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
2026-07-11 21:05:05.612 +09:00 [INF] HTTP GET /login responded 302 in 0.3471 ms
2026-07-11 21:05:05.612 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 0.603ms
2026-07-11 21:05:05.616 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-11 21:05:05.616 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-11 21:05:05.616 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-11 21:05:05.616 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-11 21:05:05.616 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-11 21:05:05.616 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-11 21:05:05.616 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-11 21:05:05.617 +09:00 [INF] Executed page /Account/Login in 0.5619ms
2026-07-11 21:05:05.617 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-11 21:05:05.617 +09:00 [INF] HTTP GET /Account/Login responded 200 in 0.9286 ms
2026-07-11 21:05:05.617 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 1.1671ms
@@ -0,0 +1,610 @@
2026-07-12 01:49:59.393 +09:00 [INF] Registered 10 endpoints in 432 milliseconds.
2026-07-12 01:49:59.756 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-12 01:50:02.068 +09:00 [INF] ✅ Database migration completed successfully
2026-07-12 01:50:05.349 +09:00 [INF] Database migration and initialization successful
2026-07-12 01:50:05.388 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2026-07-12 01:50:06.028 +09:00 [INF] Start installing Hangfire SQL objects...
2026-07-12 01:50:10.702 +09:00 [INF] Hangfire SQL objects installed.
2026-07-12 01:50:10.728 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
2026-07-12 01:50:10.877 +09:00 [ERR] Hosting failed to start
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
2026-07-12 01:50:10.905 +09:00 [FTL] Application terminated unexpectedly
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(CancellationToken cancellationToken)
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host)
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 178
2026-07-12 01:50:55.147 +09:00 [INF] Registered 10 endpoints in 161 milliseconds.
2026-07-12 01:50:55.188 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-12 01:50:57.488 +09:00 [INF] ✅ Database migration completed successfully
2026-07-12 01:51:00.589 +09:00 [INF] Database migration and initialization successful
2026-07-12 01:51:00.610 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2026-07-12 01:51:00.632 +09:00 [INF] Start installing Hangfire SQL objects...
2026-07-12 01:51:05.254 +09:00 [INF] Hangfire SQL objects installed.
2026-07-12 01:51:05.261 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
2026-07-12 01:51:05.409 +09:00 [INF] Now listening on: http://localhost:5265
2026-07-12 01:51:05.417 +09:00 [INF] Starting Hangfire Server using job storage: 'PostgreSQL Server: Host: 127.0.0.1, DB: quantenginedb, Schema: hangfire'
2026-07-12 01:51:05.417 +09:00 [INF] Using the following options for PostgreSQL job storage:
2026-07-12 01:51:05.418 +09:00 [INF] Queue poll interval: 00:00:15.
2026-07-12 01:51:05.418 +09:00 [INF] Invisibility timeout: 00:30:00.
2026-07-12 01:51:05.418 +09:00 [INF] Use sliding invisibility timeout: False.
2026-07-12 01:51:05.418 +09:00 [INF] Using the following options for Hangfire Server:
Worker count: 32
Listening queues: 'default'
Shutdown timeout: 00:00:15
Schedule polling interval: 00:00:15
2026-07-12 01:51:05.434 +09:00 [INF] Application started. Press Ctrl+C to shut down.
2026-07-12 01:51:05.434 +09:00 [INF] Hosting environment: Development
2026-07-12 01:51:05.434 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
2026-07-12 01:51:05.741 +09:00 [INF] Server kimjaehyun-note:5816:6e6d8857 successfully announced in 304.3044 ms
2026-07-12 01:51:05.744 +09:00 [INF] Server kimjaehyun-note:5816:6e6d8857 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
2026-07-12 01:51:05.762 +09:00 [INF] Server kimjaehyun-note:5816:6e6d8857 all the dispatchers started
2026-07-12 01:51:16.896 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 01:51:16.900 +09:00 [WRN] Failed to determine the https port for redirect.
2026-07-12 01:51:16.932 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 01:51:16.958 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 01:51:16.970 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 01:51:16.972 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 01:51:16.977 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:51:16.978 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:51:17.024 +09:00 [INF] Executed page /Account/Login in 63.0425ms
2026-07-12 01:51:17.025 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 01:51:17.026 +09:00 [INF] HTTP GET /Account/Login responded 200 in 126.7878 ms
2026-07-12 01:51:17.028 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 132.7688ms
2026-07-12 01:51:42.662 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 01:51:42.685 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 01:51:42.685 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 01:51:42.687 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 01:51:42.687 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 01:51:42.687 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:51:42.687 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:51:42.694 +09:00 [INF] Executed page /Account/Login in 9.2179ms
2026-07-12 01:51:42.695 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 01:51:42.695 +09:00 [INF] HTTP GET /Account/Login responded 200 in 32.3239 ms
2026-07-12 01:51:42.695 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 34.0934ms
2026-07-12 01:51:43.344 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 218
2026-07-12 01:51:43.352 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 01:51:43.353 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 01:51:43.402 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
2026-07-12 01:51:44.200 +09:00 [INF] AuthenticationScheme: AdminCookie signed in.
2026-07-12 01:51:44.201 +09:00 [INF] [Login] User 'admin' authenticated successfully from ::1
2026-07-12 01:51:44.205 +09:00 [INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.LocalRedirectResult.
2026-07-12 01:51:44.210 +09:00 [INF] Executing LocalRedirectResult, redirecting to /Admin/Dashboard.
2026-07-12 01:51:44.213 +09:00 [INF] Executed page /Account/Login in 859.92ms
2026-07-12 01:51:44.214 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 01:51:44.216 +09:00 [INF] HTTP POST /Account/Login responded 302 in 871.2169 ms
2026-07-12 01:51:44.218 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 302 0 null 873.6838ms
2026-07-12 01:51:44.222 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
2026-07-12 01:51:44.232 +09:00 [INF] Executing endpoint '/Admin/Dashboard/Index'
2026-07-12 01:51:44.252 +09:00 [INF] Route matched with {page = "/Admin/Dashboard/Index"}. Executing page /Admin/Dashboard/Index
2026-07-12 01:51:44.253 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Dashboard.IndexModel.OnGetAsync - ModelState is "Valid"
2026-07-12 01:51:45.151 +09:00 [INF] Executed handler method OnGetAsync, returned result .
2026-07-12 01:51:45.151 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:51:45.152 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:51:45.229 +09:00 [INF] Executed page /Admin/Dashboard/Index in 976.4261ms
2026-07-12 01:51:45.229 +09:00 [INF] Executed endpoint '/Admin/Dashboard/Index'
2026-07-12 01:51:45.230 +09:00 [INF] HTTP GET /Admin/Dashboard responded 200 in 1007.5596 ms
2026-07-12 01:51:45.230 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 200 null text/html; charset=utf-8 1008.1707ms
2026-07-12 01:51:45.238 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/css/admin.css - null null
2026-07-12 01:51:45.255 +09:00 [INF] Sending file. Request path: '/css/admin.css'. Physical path: 'C:\Temp\data_feed\src\dotnet\QuantEngine.Web\wwwroot\css\admin.css'
2026-07-12 01:51:45.256 +09:00 [INF] HTTP GET /css/admin.css responded 200 in 17.9079 ms
2026-07-12 01:51:45.256 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/css/admin.css - 200 3956 text/css 18.4204ms
2026-07-12 01:51:45.933 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Operations - null null
2026-07-12 01:51:45.937 +09:00 [INF] Executing endpoint '/Admin/Operations/Index'
2026-07-12 01:51:45.951 +09:00 [INF] Route matched with {page = "/Admin/Operations/Index"}. Executing page /Admin/Operations/Index
2026-07-12 01:51:45.952 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Operations.IndexModel.OnGetAsync - ModelState is "Valid"
2026-07-12 01:51:48.823 +09:00 [INF] Operations data loaded from Hangfire (4 recurring jobs, 2 servers)
2026-07-12 01:51:48.824 +09:00 [INF] Executed handler method OnGetAsync, returned result .
2026-07-12 01:51:48.824 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:51:48.824 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:51:48.846 +09:00 [INF] Executed page /Admin/Operations/Index in 2894.9938ms
2026-07-12 01:51:48.847 +09:00 [INF] Executed endpoint '/Admin/Operations/Index'
2026-07-12 01:51:48.847 +09:00 [INF] HTTP GET /Admin/Operations responded 200 in 2913.4775 ms
2026-07-12 01:51:48.847 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Operations - 200 null text/html; charset=utf-8 2914.1336ms
2026-07-12 01:51:49.732 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 01:51:49.732 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 01:51:49.732 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 01:51:49.732 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 01:51:49.733 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 01:51:49.733 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:51:49.733 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:51:49.735 +09:00 [INF] Executed page /Account/Login in 2.6158ms
2026-07-12 01:51:49.735 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 01:51:49.735 +09:00 [INF] HTTP GET /Account/Login responded 200 in 3.4623 ms
2026-07-12 01:51:49.735 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 3.8328ms
2026-07-12 01:51:50.371 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 218
2026-07-12 01:51:50.371 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 01:51:50.371 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 01:51:50.373 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
2026-07-12 01:51:50.857 +09:00 [INF] AuthenticationScheme: AdminCookie signed in.
2026-07-12 01:51:50.857 +09:00 [INF] [Login] User 'admin' authenticated successfully from ::1
2026-07-12 01:51:50.857 +09:00 [INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.LocalRedirectResult.
2026-07-12 01:51:50.858 +09:00 [INF] Executing LocalRedirectResult, redirecting to /Admin/Dashboard.
2026-07-12 01:51:50.858 +09:00 [INF] Executed page /Account/Login in 486.6264ms
2026-07-12 01:51:50.858 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 01:51:50.858 +09:00 [INF] HTTP POST /Account/Login responded 302 in 487.2066 ms
2026-07-12 01:51:50.858 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 302 0 null 487.636ms
2026-07-12 01:51:50.862 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
2026-07-12 01:51:50.865 +09:00 [INF] Executing endpoint '/Admin/Dashboard/Index'
2026-07-12 01:51:50.865 +09:00 [INF] Route matched with {page = "/Admin/Dashboard/Index"}. Executing page /Admin/Dashboard/Index
2026-07-12 01:51:50.865 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Dashboard.IndexModel.OnGetAsync - ModelState is "Valid"
2026-07-12 01:51:51.755 +09:00 [INF] Executed handler method OnGetAsync, returned result .
2026-07-12 01:51:51.755 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:51:51.755 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:51:51.757 +09:00 [INF] Executed page /Admin/Dashboard/Index in 891.4622ms
2026-07-12 01:51:51.757 +09:00 [INF] Executed endpoint '/Admin/Dashboard/Index'
2026-07-12 01:51:51.757 +09:00 [INF] HTTP GET /Admin/Dashboard responded 200 in 894.9356 ms
2026-07-12 01:51:51.757 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 200 null text/html; charset=utf-8 895.2421ms
2026-07-12 01:51:51.766 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/css/admin.css - null null
2026-07-12 01:51:51.779 +09:00 [INF] Sending file. Request path: '/css/admin.css'. Physical path: 'C:\Temp\data_feed\src\dotnet\QuantEngine.Web\wwwroot\css\admin.css'
2026-07-12 01:51:51.779 +09:00 [INF] HTTP GET /css/admin.css responded 200 in 12.9029 ms
2026-07-12 01:51:51.780 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/css/admin.css - 200 3956 text/css 13.3629ms
2026-07-12 01:52:45.383 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 01:52:45.383 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 01:52:45.383 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 01:52:45.383 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 01:52:45.383 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 01:52:45.383 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:52:45.383 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:52:45.383 +09:00 [INF] Executed page /Account/Login in 0.5097ms
2026-07-12 01:52:45.384 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 01:52:45.384 +09:00 [INF] HTTP GET /Account/Login responded 200 in 0.8798 ms
2026-07-12 01:52:45.384 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 1.0536ms
2026-07-12 01:52:46.018 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 218
2026-07-12 01:52:46.018 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 01:52:46.018 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 01:52:46.019 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
2026-07-12 01:52:46.475 +09:00 [INF] AuthenticationScheme: AdminCookie signed in.
2026-07-12 01:52:46.475 +09:00 [INF] [Login] User 'admin' authenticated successfully from ::1
2026-07-12 01:52:46.475 +09:00 [INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.LocalRedirectResult.
2026-07-12 01:52:46.475 +09:00 [INF] Executing LocalRedirectResult, redirecting to /Admin/Dashboard.
2026-07-12 01:52:46.475 +09:00 [INF] Executed page /Account/Login in 456.9281ms
2026-07-12 01:52:46.475 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 01:52:46.475 +09:00 [INF] HTTP POST /Account/Login responded 302 in 457.3632 ms
2026-07-12 01:52:46.475 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 302 0 null 457.5915ms
2026-07-12 01:52:46.478 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
2026-07-12 01:52:46.479 +09:00 [INF] Executing endpoint '/Admin/Dashboard/Index'
2026-07-12 01:52:46.479 +09:00 [INF] Route matched with {page = "/Admin/Dashboard/Index"}. Executing page /Admin/Dashboard/Index
2026-07-12 01:52:46.479 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Dashboard.IndexModel.OnGetAsync - ModelState is "Valid"
2026-07-12 01:52:47.363 +09:00 [INF] Executed handler method OnGetAsync, returned result .
2026-07-12 01:52:47.363 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:52:47.363 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:52:47.366 +09:00 [INF] Executed page /Admin/Dashboard/Index in 886.9837ms
2026-07-12 01:52:47.366 +09:00 [INF] Executed endpoint '/Admin/Dashboard/Index'
2026-07-12 01:52:47.366 +09:00 [INF] HTTP GET /Admin/Dashboard responded 200 in 888.0406 ms
2026-07-12 01:52:47.367 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 200 null text/html; charset=utf-8 888.4149ms
2026-07-12 01:52:47.378 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/css/admin.css - null null
2026-07-12 01:52:47.380 +09:00 [INF] Sending file. Request path: '/css/admin.css'. Physical path: 'C:\Temp\data_feed\src\dotnet\QuantEngine.Web\wwwroot\css\admin.css'
2026-07-12 01:52:47.380 +09:00 [INF] HTTP GET /css/admin.css responded 200 in 2.4015 ms
2026-07-12 01:52:47.380 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/css/admin.css - 200 3956 text/css 2.7681ms
2026-07-12 01:52:48.337 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Collection - null null
2026-07-12 01:52:48.338 +09:00 [INF] Executing endpoint '/Admin/Collection/Index'
2026-07-12 01:52:48.355 +09:00 [INF] Route matched with {page = "/Admin/Collection/Index"}. Executing page /Admin/Collection/Index
2026-07-12 01:52:48.355 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Collection.IndexModel.OnGetAsync - ModelState is "Valid"
2026-07-12 01:52:48.581 +09:00 [INF] Executed handler method OnGetAsync, returned result .
2026-07-12 01:52:48.581 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:52:48.581 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:52:48.591 +09:00 [INF] Executed page /Admin/Collection/Index in 236.0372ms
2026-07-12 01:52:48.591 +09:00 [INF] Executed endpoint '/Admin/Collection/Index'
2026-07-12 01:52:48.591 +09:00 [INF] HTTP GET /Admin/Collection responded 200 in 254.0162 ms
2026-07-12 01:52:48.592 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Collection - 200 null text/html; charset=utf-8 254.527ms
2026-07-12 01:52:49.211 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - null null
2026-07-12 01:52:49.211 +09:00 [INF] Executing endpoint '/Admin/Monitoring/Index'
2026-07-12 01:52:49.217 +09:00 [INF] Route matched with {page = "/Admin/Monitoring/Index"}. Executing page /Admin/Monitoring/Index
2026-07-12 01:52:49.218 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Monitoring.IndexModel.OnGetAsync - ModelState is "Valid"
2026-07-12 01:52:49.440 +09:00 [INF] Executed handler method OnGetAsync, returned result .
2026-07-12 01:52:49.440 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:52:49.440 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:52:49.453 +09:00 [INF] Executed page /Admin/Monitoring/Index in 235.3992ms
2026-07-12 01:52:49.453 +09:00 [INF] Executed endpoint '/Admin/Monitoring/Index'
2026-07-12 01:52:49.453 +09:00 [INF] HTTP GET /Admin/Monitoring responded 200 in 242.4802 ms
2026-07-12 01:52:49.453 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - 200 null text/html; charset=utf-8 242.724ms
2026-07-12 01:52:50.106 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Users - null null
2026-07-12 01:52:50.107 +09:00 [INF] Executing endpoint '/Admin/Users/Index'
2026-07-12 01:52:50.117 +09:00 [INF] Route matched with {page = "/Admin/Users/Index"}. Executing page /Admin/Users/Index
2026-07-12 01:52:50.118 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Users.IndexModel.OnGetAsync - ModelState is "Valid"
2026-07-12 01:52:50.339 +09:00 [INF] Executed handler method OnGetAsync, returned result .
2026-07-12 01:52:50.339 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:52:50.339 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:52:50.346 +09:00 [INF] Executed page /Admin/Users/Index in 228.3491ms
2026-07-12 01:52:50.346 +09:00 [INF] Executed endpoint '/Admin/Users/Index'
2026-07-12 01:52:50.346 +09:00 [INF] HTTP GET /Admin/Users responded 200 in 239.3317 ms
2026-07-12 01:52:50.346 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Users - 200 null text/html; charset=utf-8 239.5416ms
2026-07-12 01:56:32.591 +09:00 [INF] Registered 10 endpoints in 168 milliseconds.
2026-07-12 01:56:32.627 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-12 01:56:34.908 +09:00 [INF] ✅ Database migration completed successfully
2026-07-12 01:56:37.976 +09:00 [INF] Database migration and initialization successful
2026-07-12 01:56:38.000 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2026-07-12 01:56:38.043 +09:00 [INF] Start installing Hangfire SQL objects...
2026-07-12 01:56:42.666 +09:00 [INF] Hangfire SQL objects installed.
2026-07-12 01:56:42.681 +09:00 [INF] Initializing Hangfire schedules...
2026-07-12 01:56:50.823 +09:00 [INF] Hangfire schedules initialized successfully
2026-07-12 01:56:50.988 +09:00 [INF] Now listening on: http://localhost:5265
2026-07-12 01:56:50.992 +09:00 [INF] Starting Hangfire Server using job storage: 'PostgreSQL Server: Host: 127.0.0.1, DB: quantenginedb, Schema: hangfire'
2026-07-12 01:56:50.992 +09:00 [INF] Using the following options for PostgreSQL job storage:
2026-07-12 01:56:50.992 +09:00 [INF] Queue poll interval: 00:00:15.
2026-07-12 01:56:50.992 +09:00 [INF] Invisibility timeout: 00:30:00.
2026-07-12 01:56:50.992 +09:00 [INF] Use sliding invisibility timeout: False.
2026-07-12 01:56:50.992 +09:00 [INF] Using the following options for Hangfire Server:
Worker count: 32
Listening queues: 'default'
Shutdown timeout: 00:00:15
Schedule polling interval: 00:00:15
2026-07-12 01:56:51.000 +09:00 [INF] Application started. Press Ctrl+C to shut down.
2026-07-12 01:56:51.000 +09:00 [INF] Hosting environment: Development
2026-07-12 01:56:51.000 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
2026-07-12 01:56:51.234 +09:00 [INF] Server kimjaehyun-note:18688:50858618 successfully announced in 231.3723 ms
2026-07-12 01:56:51.239 +09:00 [INF] Server kimjaehyun-note:18688:50858618 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
2026-07-12 01:56:51.251 +09:00 [INF] Server kimjaehyun-note:18688:50858618 all the dispatchers started
2026-07-12 01:56:52.918 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 01:56:52.923 +09:00 [WRN] Failed to determine the https port for redirect.
2026-07-12 01:56:52.975 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 01:56:53.000 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 01:56:53.008 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 01:56:53.009 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 01:56:53.011 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 01:56:53.012 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 01:56:53.043 +09:00 [INF] Executed page /Account/Login in 38.7733ms
2026-07-12 01:56:53.043 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 01:56:53.045 +09:00 [INF] HTTP GET /Account/Login responded 200 in 123.2950 ms
2026-07-12 01:56:53.048 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 131.2244ms
2026-07-12 10:29:03.142 +09:00 [INF] Registered 10 endpoints in 4,906 milliseconds.
2026-07-12 10:29:03.556 +09:00 [INF] 🔄 Starting database migration with DbUp...
2026-07-12 10:29:07.251 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-12 10:29:07.251 +09:00 [ERR] ❌ Database migration failed
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
2026-07-12 10:29:07.276 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
2026-07-12 10:29:07.442 +09:00 [INF] Initializing Hangfire schedules...
2026-07-12 10:29:07.565 +09:00 [INF] Hangfire schedules initialized successfully
2026-07-12 10:29:07.677 +09:00 [INF] Creating key {49191b44-5683-456a-b1ee-0ddbc2c7fc29} with creation date 2026-07-12 01:29:07Z, activation date 2026-07-12 01:29:07Z, and expiration date 2026-10-10 01:29:07Z.
2026-07-12 10:29:07.689 +09:00 [WRN] No XML encryptor configured. Key {49191b44-5683-456a-b1ee-0ddbc2c7fc29} may be persisted to storage in unencrypted form.
2026-07-12 10:29:07.697 +09:00 [INF] Writing data to file 'C:\Users\kjh20\AppData\Local\quantengine-keys\key-49191b44-5683-456a-b1ee-0ddbc2c7fc29.xml'.
2026-07-12 10:29:07.829 +09:00 [INF] Now listening on: http://localhost:5265
2026-07-12 10:29:07.833 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
2026-07-12 10:29:07.834 +09:00 [INF] Using the following options for Hangfire Server:
Worker count: 32
Listening queues: 'default'
Shutdown timeout: 00:00:15
Schedule polling interval: 00:00:15
2026-07-12 10:29:07.843 +09:00 [INF] Application started. Press Ctrl+C to shut down.
2026-07-12 10:29:07.843 +09:00 [INF] Hosting environment: Development
2026-07-12 10:29:07.843 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
2026-07-12 10:29:07.850 +09:00 [INF] Server kimjaehyun-note:27604:c5019131 successfully announced in 5.0644 ms
2026-07-12 10:29:07.852 +09:00 [INF] Server kimjaehyun-note:27604:c5019131 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
2026-07-12 10:29:07.863 +09:00 [INF] Server kimjaehyun-note:27604:c5019131 all the dispatchers started
2026-07-12 10:29:10.923 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 10:29:10.929 +09:00 [WRN] Failed to determine the https port for redirect.
2026-07-12 10:29:10.969 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 10:29:10.987 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 10:29:10.994 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 10:29:10.995 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 10:29:10.997 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 10:29:10.998 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 10:29:11.026 +09:00 [INF] Executed page /Account/Login in 35.8464ms
2026-07-12 10:29:11.027 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 10:29:11.028 +09:00 [INF] HTTP GET /Account/Login responded 200 in 99.7375 ms
2026-07-12 10:29:11.030 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 121.6214ms
2026-07-12 10:54:40.138 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
2026-07-12 10:54:40.144 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
2026-07-12 10:54:40.145 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
2026-07-12 10:54:40.145 +09:00 [INF] HTTP GET /login responded 302 in 6.0857 ms
2026-07-12 10:54:40.146 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 9.206ms
2026-07-12 10:54:40.153 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 10:54:40.163 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 10:54:40.163 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 10:54:40.165 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 10:54:40.165 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 10:54:40.165 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 10:54:40.166 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 10:54:40.179 +09:00 [INF] Executed page /Account/Login in 15.2716ms
2026-07-12 10:54:40.179 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 10:54:40.179 +09:00 [INF] HTTP GET /Account/Login responded 200 in 25.4427 ms
2026-07-12 10:54:40.180 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 26.1015ms
2026-07-12 10:54:40.185 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
2026-07-12 10:54:40.186 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
2026-07-12 10:54:40.187 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
2026-07-12 10:54:40.187 +09:00 [INF] HTTP GET /login responded 302 in 1.4987 ms
2026-07-12 10:54:40.187 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 2.2724ms
2026-07-12 10:54:40.190 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 10:54:40.191 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 10:54:40.191 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 10:54:40.195 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 10:54:40.196 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 10:54:40.196 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 10:54:40.196 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 10:54:40.203 +09:00 [INF] Executed page /Account/Login in 11.1274ms
2026-07-12 10:54:40.203 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 10:54:40.203 +09:00 [INF] HTTP GET /Account/Login responded 200 in 12.4260 ms
2026-07-12 10:54:40.203 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 13.0096ms
2026-07-12 10:54:44.086 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 10:54:44.087 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 10:54:44.087 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 10:54:44.087 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 10:54:44.087 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 10:54:44.088 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 10:54:44.088 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 10:54:44.088 +09:00 [INF] Executed page /Account/Login in 1.1538ms
2026-07-12 10:54:44.089 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 10:54:44.089 +09:00 [INF] HTTP GET /Account/Login responded 200 in 1.8741 ms
2026-07-12 10:54:44.089 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 2.4044ms
2026-07-12 10:54:44.647 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 212
2026-07-12 10:54:44.649 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 10:54:44.649 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 10:54:44.686 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
2026-07-12 10:54:48.980 +09:00 [INF] Executed page /Account/Login in 4330.4506ms
2026-07-12 10:54:48.981 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 10:54:48.984 +09:00 [ERR] HTTP POST /Account/Login responded 500 in 4336.7156 ms
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
Exception data:
Severity: FATAL
SqlState: 28P01
MessageText: password authentication failed for user "quantengine_app"
File: auth.c
Line: 317
Routine: auth_failed
2026-07-12 10:54:49.034 +09:00 [ERR] An unhandled exception has occurred while executing the request.
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
Exception data:
Severity: FATAL
SqlState: 28P01
MessageText: password authentication failed for user "quantengine_app"
File: auth.c
Line: 317
Routine: auth_failed
2026-07-12 10:54:49.111 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 500 null text/html; charset=utf-8 4464.5985ms
2026-07-12 10:54:49.205 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - null null
2026-07-12 10:54:49.207 +09:00 [INF] Executing endpoint 'HTTP: GET /api/collection/runs'
2026-07-12 10:54:52.982 +09:00 [INF] Executed endpoint 'HTTP: GET /api/collection/runs'
2026-07-12 10:54:52.982 +09:00 [ERR] HTTP GET /api/collection/runs responded 500 in 3777.5166 ms
2026-07-12 10:54:52.983 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - 500 null application/problem+json; charset=utf-8 3778.0479ms
2026-07-12 13:02:07.505 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
2026-07-12 13:02:07.523 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
2026-07-12 13:02:07.523 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
2026-07-12 13:02:07.524 +09:00 [INF] HTTP GET /login responded 302 in 16.0250 ms
2026-07-12 13:02:07.529 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 21.1915ms
2026-07-12 13:02:07.531 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 13:02:07.531 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 13:02:07.540 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 13:02:07.546 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 13:02:07.548 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 13:02:07.548 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 13:02:07.550 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 13:02:07.581 +09:00 [INF] Executed page /Account/Login in 40.445ms
2026-07-12 13:02:07.581 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 13:02:07.581 +09:00 [INF] HTTP GET /Account/Login responded 200 in 49.9978 ms
2026-07-12 13:02:07.581 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 50.4194ms
2026-07-12 13:02:07.585 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
2026-07-12 13:02:07.585 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
2026-07-12 13:02:07.585 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
2026-07-12 13:02:07.585 +09:00 [INF] HTTP GET /login responded 302 in 0.3455 ms
2026-07-12 13:02:07.585 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 0.6015ms
2026-07-12 13:02:07.586 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 13:02:07.587 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 13:02:07.587 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 13:02:07.587 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 13:02:07.587 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 13:02:07.588 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 13:02:07.588 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 13:02:07.589 +09:00 [INF] Executed page /Account/Login in 1.3984ms
2026-07-12 13:02:07.589 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 13:02:07.589 +09:00 [INF] HTTP GET /Account/Login responded 200 in 2.2170 ms
2026-07-12 13:02:07.589 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 2.4854ms
2026-07-12 13:02:11.141 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
2026-07-12 13:02:11.142 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 13:02:11.142 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 13:02:11.142 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
2026-07-12 13:02:11.143 +09:00 [INF] Executed handler method OnGet, returned result .
2026-07-12 13:02:11.143 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
2026-07-12 13:02:11.143 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
2026-07-12 13:02:11.144 +09:00 [INF] Executed page /Account/Login in 1.7154ms
2026-07-12 13:02:11.144 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 13:02:11.144 +09:00 [INF] HTTP GET /Account/Login responded 200 in 2.8689 ms
2026-07-12 13:02:11.144 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 3.2765ms
2026-07-12 13:02:11.640 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 212
2026-07-12 13:02:11.643 +09:00 [INF] Executing endpoint '/Account/Login'
2026-07-12 13:02:11.644 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
2026-07-12 13:02:11.684 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
2026-07-12 13:02:15.491 +09:00 [INF] Executed page /Account/Login in 3846.9073ms
2026-07-12 13:02:15.493 +09:00 [INF] Executed endpoint '/Account/Login'
2026-07-12 13:02:15.495 +09:00 [ERR] HTTP POST /Account/Login responded 500 in 3854.1437 ms
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
Exception data:
Severity: FATAL
SqlState: 28P01
MessageText: password authentication failed for user "quantengine_app"
File: auth.c
Line: 317
Routine: auth_failed
2026-07-12 13:02:15.530 +09:00 [ERR] An unhandled exception has occurred while executing the request.
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
Exception data:
Severity: FATAL
SqlState: 28P01
MessageText: password authentication failed for user "quantengine_app"
File: auth.c
Line: 317
Routine: auth_failed
2026-07-12 13:02:15.576 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 500 null text/html; charset=utf-8 3935.4857ms
2026-07-12 13:02:15.664 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - null null
2026-07-12 13:02:15.668 +09:00 [INF] Executing endpoint 'HTTP: GET /api/collection/runs'
2026-07-12 13:02:19.317 +09:00 [INF] Executed endpoint 'HTTP: GET /api/collection/runs'
2026-07-12 13:02:19.318 +09:00 [ERR] HTTP GET /api/collection/runs responded 500 in 3653.6595 ms
2026-07-12 13:02:19.318 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - 500 null application/problem+json; charset=utf-8 3654.0261ms
Binary file not shown.
+3 -1
View File
@@ -908,6 +908,8 @@ def validate_account_snapshot_rows(rows: list[dict[str, Any]]) -> list[str]:
errors.append(f"account_snapshot row {idx}: invalid account_type {account_type!r}") errors.append(f"account_snapshot row {idx}: invalid account_type {account_type!r}")
if not ticker and name != "예수금/D+2현금": if not ticker and name != "예수금/D+2현금":
errors.append(f"account_snapshot row {idx}: ticker required") errors.append(f"account_snapshot row {idx}: ticker required")
if ticker and not re.fullmatch(r"(?:\d{6}|[A-Z0-9]{6}|[A-Z]{1,5})", ticker):
errors.append(f"account_snapshot row {idx}: ticker must be 6 digits or an uppercase symbol")
if not name: if not name:
errors.append(f"account_snapshot row {idx}: name required") errors.append(f"account_snapshot row {idx}: name required")
if parse_status not in ALLOWED_PARSE_STATUS: if parse_status not in ALLOWED_PARSE_STATUS:
@@ -936,7 +938,7 @@ def validate_account_snapshot_rows(rows: list[dict[str, Any]]) -> list[str]:
errors.append(f"account_snapshot row {idx}: CAPTURE_READ_OK rows require user_confirmed=Y") errors.append(f"account_snapshot row {idx}: CAPTURE_READ_OK rows require user_confirmed=Y")
if entry_stage and canonical.get("entry_stage", {}).get("allowed") and entry_stage not in canonical["entry_stage"]["allowed"]: if entry_stage and canonical.get("entry_stage", {}).get("allowed") and entry_stage not in canonical["entry_stage"]["allowed"]:
errors.append(f"account_snapshot row {idx}: invalid entry_stage {entry_stage!r}") errors.append(f"account_snapshot row {idx}: invalid entry_stage {entry_stage!r}")
if position_type and canonical.get("position_type", {}).get("allowed") and position_type not in canonical["position_type"]["allowed"]: if position_type and name != "예수금/D+2현금" and canonical.get("position_type", {}).get("allowed") and position_type not in canonical["position_type"]["allowed"]:
errors.append(f"account_snapshot row {idx}: invalid position_type {position_type!r}") errors.append(f"account_snapshot row {idx}: invalid position_type {position_type!r}")
return errors return errors
+4 -2
View File
@@ -1,4 +1,6 @@
{ {
"status": "passed", "status": "failed",
"failedTests": [] "failedTests": [
"90c6053e24d905f92d61-fe6c7d0382f9666a596d"
]
} }
@@ -0,0 +1,259 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: evidence\qe-m1-02-collection-run.spec.ts >> QE-M1-02: Collection Run List & Detail Verification >> QE-M1-02: Collection run renders in list with API-derived expected values
- Location: tests\e2e\evidence\qe-m1-02-collection-run.spec.ts:24:3
# Error details
```
Error: expect(received).toBeTruthy()
Received: false
```
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- heading "An unhandled exception occurred while processing the request." [level=1] [ref=e2]
- generic [ref=e3]: "PostgresException: 28P01: password authentication failed for user \"quantengine_app\""
- paragraph [ref=e4]: Npgsql.Internal.NpgsqlConnector.ReadMessageLong(bool async, DataRowLoadingMode dataRowLoadingMode, bool readingNotifications, bool isReadingPrependedMessage)
- list [ref=e5]:
- listitem [ref=e6] [cursor=pointer]: Stack
- listitem [ref=e7] [cursor=pointer]: Query
- listitem [ref=e8] [cursor=pointer]: Cookies
- listitem [ref=e9] [cursor=pointer]: Headers
- listitem [ref=e10] [cursor=pointer]: Routing
- list [ref=e12]:
- listitem [ref=e13]:
- 'heading "PostgresException: 28P01: password authentication failed for user \"quantengine_app\"" [level=2] [ref=e14]'
- list [ref=e15]:
- listitem [ref=e16]:
- heading "Npgsql.Internal.NpgsqlConnector.ReadMessageLong(bool async, DataRowLoadingMode dataRowLoadingMode, bool readingNotifications, bool isReadingPrependedMessage)" [level=3] [ref=e17]
- listitem [ref=e18]:
- heading "System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder<TResult>+StateMachineBox<TStateMachine>.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(short token)" [level=3] [ref=e19]
- listitem [ref=e20]:
- heading "Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List<string> mechanisms, string username, bool async, CancellationToken cancellationToken)" [level=3] [ref=e21]
- listitem [ref=e22]:
- heading "Npgsql.Internal.NpgsqlConnector.Authenticate(string username, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e23]
- listitem [ref=e24]:
- heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e25]
- listitem [ref=e26]:
- heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e27]
- listitem [ref=e28]:
- heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e29]
- listitem [ref=e30]:
- heading "Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e31]
- listitem [ref=e32]:
- heading "Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e33]
- listitem [ref=e34]:
- heading "System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable<TResult>+ConfiguredValueTaskAwaiter.GetResult()" [level=3] [ref=e35]
- listitem [ref=e36]:
- heading "Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e37]
- listitem [ref=e38]:
- heading "System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable<TResult>+ConfiguredValueTaskAwaiter.GetResult()" [level=3] [ref=e39]
- listitem [ref=e40]:
- heading "Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(bool async, CancellationToken cancellationToken)" [level=3] [ref=e41]
- listitem [ref=e42]:
- heading "Dapper.SqlMapper.QueryRowAsync<T>(IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in SqlMapper.Async.cs" [level=3] [ref=e43]:
- text: Dapper.SqlMapper.QueryRowAsync<T>(IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in
- code [ref=e44]: SqlMapper.Async.cs
- listitem [ref=e45]:
- heading "QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(string username) in WorkspaceRepository.cs" [level=3] [ref=e46]:
- text: QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(string username) in
- code [ref=e47]: WorkspaceRepository.cs
- button "+" [ref=e48] [cursor=pointer]
- list [ref=e50]:
- listitem [ref=e51]: return await conn.QueryFirstOrDefaultAsync<WorkspaceAccount>(@"
- listitem [ref=e52]:
- heading "QuantEngine.Web.Services.AuthService.AuthenticateAsync(string username, string password, string ipAddress) in AuthService.cs" [level=3] [ref=e53]:
- text: QuantEngine.Web.Services.AuthService.AuthenticateAsync(string username, string password, string ipAddress) in
- code [ref=e54]: AuthService.cs
- button "+" [ref=e55] [cursor=pointer]
- list [ref=e57]:
- listitem [ref=e58]: var account = await _workspaceRepository.GetAccountByUsernameAsync(username.Trim());
- listitem [ref=e59]:
- heading "QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(string username, string password, bool rememberUsername) in Login.cshtml.cs" [level=3] [ref=e60]:
- text: QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(string username, string password, bool rememberUsername) in
- code [ref=e61]: Login.cshtml.cs
- button "+" [ref=e62] [cursor=pointer]
- list [ref=e64]:
- listitem [ref=e65]: var account = await _authService.AuthenticateAsync(username, password, ipAddress);
- listitem [ref=e66]:
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory+GenericTaskHandlerMethod.Convert<T>(object taskAsObject)" [level=3] [ref=e67]
- listitem [ref=e68]:
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory+GenericTaskHandlerMethod.Execute(object receiver, object[] arguments)" [level=3] [ref=e69]
- listitem [ref=e70]:
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()" [level=3] [ref=e71]
- listitem [ref=e72]:
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()" [level=3] [ref=e73]
- listitem [ref=e74]:
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)" [level=3] [ref=e75]
- listitem [ref=e76]:
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted)" [level=3] [ref=e77]
- listitem [ref=e78]:
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()" [level=3] [ref=e79]
- listitem [ref=e80]:
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, object state, bool isCompleted)" [level=3] [ref=e81]
- listitem [ref=e82]:
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)" [level=3] [ref=e83]
- listitem [ref=e84]:
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted)" [level=3] [ref=e85]
- listitem [ref=e86]:
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, object state, bool isCompleted)" [level=3] [ref=e87]
- listitem [ref=e88]:
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)" [level=3] [ref=e89]
- listitem [ref=e90]:
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)" [level=3] [ref=e91]
- listitem [ref=e92]:
- heading "Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)" [level=3] [ref=e93]
- listitem [ref=e94]:
- heading "Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)" [level=3] [ref=e95]
- listitem [ref=e96]:
- heading "Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)" [level=3] [ref=e97]
- listitem [ref=e98]:
- heading "Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)" [level=3] [ref=e99]
- listitem [ref=e100]:
- heading "Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)" [level=3] [ref=e101]
- listitem [ref=e102]:
- button "Show raw exception details" [ref=e104] [cursor=pointer]
```
# Test source
```ts
1 | import { test, expect } from '@playwright/test';
2 | import * as fs from 'fs';
3 | import * as path from 'path';
4 |
5 | test.describe('QE-M1-02: Collection Run List & Detail Verification', () => {
6 | // Login before each test
7 | test.beforeEach(async ({ page }) => {
8 | await page.goto('/Account/Login');
9 | await page.waitForLoadState('domcontentloaded');
10 |
11 | // Fill login form with credentials (admin/admin)
12 | const usernameInput = page.locator('#username');
13 | const passwordInput = page.locator('#password');
14 | const loginButton = page.locator('#loginBtn');
15 |
16 | await usernameInput.fill('admin');
17 | await passwordInput.fill('admin');
18 | await loginButton.click();
19 |
20 | // Wait for login to complete
21 | await page.waitForLoadState('domcontentloaded');
22 | });
23 |
24 | test('QE-M1-02: Collection run renders in list with API-derived expected values', async ({ page }) => {
25 | // Step 1: Fetch expected values from API (source of truth)
26 | const apiResponse = await page.request.get('/api/collection/runs?limit=20');
> 27 | expect(apiResponse.ok()).toBeTruthy();
| ^ Error: expect(received).toBeTruthy()
28 |
29 | const responseJson = await apiResponse.json();
30 | const runs = (responseJson as any).runs || [];
31 |
32 | // Fail if no collection runs exist in database
33 | if (runs.length === 0) {
34 | throw new Error(
35 | 'No collection runs in DB — run the daily-collection job first. ' +
36 | 'Expected at least 1 run from kis_collection_runs table.'
37 | );
38 | }
39 |
40 | // Extract expected values from most recent run (first in list)
41 | const expectedRun = runs[0];
42 | const expectedRunId = expectedRun.runId;
43 | const expectedTotalSnapshots = expectedRun.totalSnapshots ?? 0;
44 | const expectedStatus = expectedRun.status; // e.g., "completed", "running", "failed"
45 |
46 | // Map status to Korean text (same logic as Index.cshtml — unknown statuses
47 | // like COMPLETED_WITH_ERRORS render the raw status string in a secondary badge)
48 | let expectedStatusText = String(expectedStatus ?? '');
49 | if (expectedStatus?.toLowerCase() === 'completed') {
50 | expectedStatusText = '완료';
51 | } else if (expectedStatus?.toLowerCase() === 'running') {
52 | expectedStatusText = '진행 중';
53 | } else if (expectedStatus?.toLowerCase() === 'failed') {
54 | expectedStatusText = '실패';
55 | }
56 |
57 | console.log(
58 | `\n=== QE-M1-02 Test Started ===\n` +
59 | `Expected RunId: ${expectedRunId}\n` +
60 | `Expected TotalSnapshots: ${expectedTotalSnapshots}\n` +
61 | `Expected Status: ${expectedStatus} (rendered as: ${expectedStatusText})\n`
62 | );
63 |
64 | // Step 2: Navigate to Collection admin page
65 | await page.goto('/Admin/Collection');
66 | await page.waitForLoadState('domcontentloaded');
67 |
68 | // Step 3: Verify page title contains "데이터 수집" (collection)
69 | const pageTitle = await page.title();
70 | expect(pageTitle).toContain('데이터 수집');
71 |
72 | // Step 4: Assert that a row containing the expected runId is visible
73 | const runIdCell = page.locator(`td:has-text("${expectedRunId}")`);
74 | await expect(runIdCell).toBeVisible();
75 | console.log(`✓ RunId row found and visible: ${expectedRunId}`);
76 |
77 | // Step 5: Find the row containing this runId and verify the snapshot count
78 | const tableRow = runIdCell.locator('xpath=ancestor::tr');
79 |
80 | // Within the row, find all td elements and map to columns
81 | // Columns: 실행 ID (0), 시작 시간 (1), 종료 시간 (2), 상태 (3), 스냅샷 수 (4), 오류 수 (5)
82 | const cells = tableRow.locator('td');
83 | const cellCount = await cells.count();
84 | expect(cellCount).toBeGreaterThanOrEqual(5); // At least 5 columns
85 |
86 | // Cell 4 (index 4) is "스냅샷 수" (total snapshots)
87 | const snapshotCell = cells.nth(4);
88 | const snapshotText = await snapshotCell.textContent();
89 | expect(snapshotText?.trim()).toBe(String(expectedTotalSnapshots));
90 | console.log(`✓ Snapshot count matches: ${snapshotText?.trim()} == ${expectedTotalSnapshots}`);
91 |
92 | // Cell 3 (index 3) is "상태" (status badge)
93 | const statusCell = cells.nth(3);
94 | const statusBadge = statusCell.locator('span.badge');
95 | const statusBadgeText = await statusBadge.textContent();
96 | expect(statusBadgeText?.trim()).toBe(expectedStatusText);
97 | console.log(`✓ Status badge matches: ${statusBadgeText?.trim()} == ${expectedStatusText}`);
98 |
99 | // Step 6: Create screenshot directory and take screenshot of collection list
100 | const screenshotDir = path.join(process.cwd(), 'Temp', 'evidence', 'QE-M1-02', 'screenshots');
101 | fs.mkdirSync(screenshotDir, { recursive: true });
102 |
103 | await page.screenshot({
104 | path: path.join(screenshotDir, '01-collection-page.png'),
105 | fullPage: true,
106 | });
107 | console.log(`✓ Screenshot saved: 01-collection-page.png`);
108 |
109 | // Step 7: Navigate to the run detail page
110 | // The detail page route is /Admin/Collection/{runId}
111 | await page.goto(`/Admin/Collection/${expectedRunId}`);
112 | await page.waitForLoadState('domcontentloaded');
113 |
114 | // Step 8: Verify detail page title contains the runId
115 | const detailPageTitle = await page.title();
116 | expect(detailPageTitle).toContain('수집 실행 상세');
117 |
118 | // Step 9: Verify that the RunId is displayed on the detail page
119 | // The page title shows: "수집 실행 상세 - {runId}"
120 | const pageHeading = page.locator('h2.page-title');
121 | const headingText = await pageHeading.textContent();
122 | expect(headingText).toContain(expectedRunId);
123 | console.log(`✓ Detail page title contains RunId: ${headingText}`);
124 |
125 | // Step 10: Verify snapshots count is displayed on detail page
126 | // The snapshot count appears in a card with "스냅샷 수" as the title
127 | const snapshotCountCard = page.locator('h4.card-title:has-text("스냅샷 수")');
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Some files were not shown because too many files have changed in this diff Show More